Abaks: Views

The last part, in order to make our API is to be able to query it, that's why we have to introduce Views.

Since our project is rather simple, we have made opinionated design choices in our CQRS implementation, we need a View Model, but instead of having Materialized Views or Projections, we'll based our implementation directly on the Events.

Noticeably, we want to be able to get the Period:

data Period = Period
  { periodId :: PeriodId,
    name :: Text,
    from :: Day,
    to :: Day,
    initialBalance :: Amount,
    balance :: Amount,
    entries :: Map.Map EntryId Entry
  }

We could then define a View:

type View a r = Events a -> r

viewPeriod :: View AbaksEvent (Maybe Period)
viewPeriod =
  withStartedEvent $ \started ->
    foldl' go $
      Period
        { periodId = started.periodId,
          name = started.name,
          from = started.from,
          to = started.to,
          balance = started.initialBalance,
          initialBalance = started.initialBalance,
          entries = mempty
        }
  where
    go acc =
      \case
        Started _ -> acc
        EntryAdded e ->
          adjustAmount (Amount 0) e.entry.amount $
            withEntries acc $
              Map.insert e.entry.entryId e.entry
        EntryAmountChanged e ->
          adjustAmount (maybe (Amount 0) (.amount) $ Map.lookup e.entryId acc.entries) e.amount $
            withEntry acc e.entryId $ \entry ->
              entry {amount = e.amount}
        EntryValidated e ->
          withEntry acc e.entryId $ \entry ->
            entry {state = Validated}
        EntryCommented e ->
          withEntry acc e.entryId $ \entry ->
            entry {comment = e.comment}
        EntryMarkedInConflict e ->
          withEntry acc e.entryId $ \entry ->
            entry {state = InConflict e.reason}
        EntryDeleted e ->
          withEntries acc $
            Map.delete e.entryId
    withEntries period f = period {entries = f period.entries}
    withEntry period entryId f = withEntries period $ Map.adjust f entryId
    adjustAmount :: Amount -> Amount -> Period -> Period
    adjustAmount previous new period =
      period
        { balance = Amount $ period.balance.getAmountInCents - previous.getAmountInCents + new.getAmountInCents
        }

Clearly, than a lot of code, let's break this down:

  • withStartedEvent splits the stream, extracting Started
  • Then we fold over then Events with Period initialized with Started's data
  • For each Event we have to update Period

The great thing with this approach is the freedom we have to define per-query independent piece of code, having a fine-grain interpretation of each Event, meaning, the precision of our Views is bound to the precision of our Events.