Projections

A projection reconstructs state from a sequence of events. It’s the central abstraction for turning “what happened” back into “where we are now.”

The type

data Projection state event = Projection
  { seed :: state,
    eventHandler :: state -> event -> state
  }

seed is the initial state, before any events. eventHandler takes the current state and one event and returns the next state. That’s the whole thing — a Projection is a fold specification packaged as a first-class value.

Here’s a bank account projection:

data Account = Account
  { balance :: Double,
    owner :: Maybe UUID
  }

accountProjection :: Projection Account AccountEvent
accountProjection = Projection (Account 0 Nothing) apply
  where
    apply acc (AccountOpened o funding) = acc {owner = Just o, balance = funding}
    apply acc (AccountCredited amt) = acc {balance = acc.balance + amt}
    apply acc (AccountDebited amt) = acc {balance = acc.balance - amt}

Each case is a direct translation of “what does this event mean for the state.”

Running a projection

To fold a projection over any Foldable of events — a list, a Seq, whatever you have — use latestProjection:

latestProjection :: (Foldable t) => Projection state event -> t event -> state
-- Rebuild state from a list of events, purely.
currentBalance :: [AccountEvent] -> Double
currentBalance events = (latestProjection accountProjection events).balance

No IO, no database round-trip — just a fold. This is what makes projections so easy to test: you can unit-test your entire state-reconstruction logic by passing in a list of events and checking the result. No test database, no mocking, no setup.

Projecting from a store

When the events live in an event store, getLatestStreamProjection reads a single aggregate’s stream and folds it into a StreamProjection, which carries both the reconstructed state and the stream’s current position:

getLatestStreamProjection ::
  (Monad m, Num position) =>
  EventStoreReader key position m (StreamEvent key position event) ->
  StreamProjection key position state event ->
  m (StreamProjection key position state event)

versionedStreamProjection builds the StreamProjection for one aggregate (keyed by UUID):

sp <- getLatestStreamProjection reader (versionedStreamProjection accountId accountProjection)
print sp.state      -- the reconstructed Account
print sp.position   -- its current version, used for optimistic concurrency

That position is exactly what a command handler asserts against when it writes.

Crossing boundaries

Two adapters let a projection work with types other than its own:

Next

Projections reconstruct state; command handlers use that state to validate commands and decide which events to append.