Event-sourcing and projection

In our previous log, we have described an effect-based event sourcing system.

After giving it some thought, we can use the same idea for projections.

The basic idea behind this is to introduce an interceptor, like an interpreter, without effect consumption.

It should be placed right after event storage.

There are two ways to build it:

  • Adding a new effect
  • Creating the interceptor over PlainEventSourceEffect

The first option is heavy, while the second requires either a lot of plumbing (in polysemy, high over effect and high over interpreter do not mix) or dropping the high order effect.

We can start by simplifying the effect as follows:

data PlainEventSourceEffect (eventType :: Type) (m :: Type -> Type) (a :: Type) where
  FetchPlainEvents ::
    AggregateId ->
    PlainEventSourceEffect eventType m (Events eventType)
  StorePlainEvents ::
    AggregateId ->
    Events eventType ->
    PlainEventSourceEffect eventType m ()
  ListPlainAggregateIds ::
    Proxy eventType ->
    PlainEventSourceEffect eventType m [AggregateId]

To limit breakage, we can come up with the following helper, which is a substitution of the operation:

withPlainEvents ::
  forall eventType a r.
  (Member (PlainEventSourceEffect eventType) r) =>
  AggregateId ->
  (Events eventType -> Sem r (Events eventType, a)) ->
  Sem r a
withPlainEvents aggregateId f = do
  originalAggregateEvents <- fetchPlainEvents aggregateId
  (newEvents, x) <- f originalAggregateEvents
  storePlainEvents aggregateId newEvents
  return x

This leads to a drastic simplification of the in-memory interpreter, as follows:

type PlainMemoryStore eventType = Map.Map AggregateId (Events eventType)

runEventSourceMemory ::
  forall eventType r.
  InterpreterFor (PlainEventSourceEffect eventType) r
runEventSourceMemory =
  evalState @(PlainMemoryStore eventType) mempty
    . go
    . raiseUnder @(State (PlainMemoryStore eventType))
  where
    go :: InterpreterFor (PlainEventSourceEffect eventType) (State (PlainMemoryStore eventType) ': r)
    go =
      interpret $
        \case
          FetchPlainEvents aggregateId ->
            gets @(Map.Map AggregateId (Events eventType)) (Map.findWithDefault mempty aggregateId)
          StorePlainEvents aggregateId newEvents ->
            modify $ Map.alter (Just . maybe newEvents (<> newEvents)) aggregateId
          ListPlainAggregateIds _ -> do
            gets @(Map.Map AggregateId (Events eventType)) Map.keys

That being done, we can come up with the following interceptor:

withProjection ::
  forall eventType r a x.
  (AggregateId -> Events eventType -> Sem r x) ->
  Sem (PlainEventSourceEffect eventType ': r) a ->
  Sem (PlainEventSourceEffect eventType ': r) a
withProjection project =
  reinterpret $
    \case
      FetchPlainEvents aggregateId ->
        fetchPlainEvents aggregateId
      StorePlainEvents aggregateId newEvents -> do
        storePlainEvents aggregateId newEvents
        void $ raise $ project aggregateId newEvents
      ListPlainAggregateIds p -> do
        listPlainAggregateIds p

As stated in the introduction, we simply pass through the operations to other consumers, applying the callback after the storage.

As a reminder, have the following events:

data TourEvent
  = TourCreated {name :: Text, start :: SpaceTime, end :: SpaceTime, groupSize :: Int}
  | BookingRequested {id :: BookingRequestId, buyer :: PersonIdentity, groupSize :: Int}
  | BookingAccepted {id :: BookingRequestId}
  deriving stock (Eq, Show, Generic)
  deriving (FromJSON, ToJSON) via (Generically TourEvent)

A natural projection is to keep a list of up-to-date tours and bookings; we can start with the following domain:

data Tour = Tour
  { name :: Text,
    start :: SpaceTime,
    end :: SpaceTime,
    groupSize :: Int,
    bookings :: Bookings
  }
  deriving stock (Eq, Show)

type Bookings = Map.Map BookingRequestId Booking

updateTourBookings :: (Bookings -> Bookings) -> Tour -> Tour
updateTourBookings f Tour {..} =
  Tour {bookings = f bookings, ..}

data Booking = Booking
  { id :: BookingRequestId,
    buyer :: PersonIdentity,
    groupSize :: Int,
    accepted :: Bool
  }
  deriving stock (Eq, Show)

The next step is to come up with a projection, applying each event to update the structure, as follows:

type BookingsMemoryState = Map.Map AggregateId Tour

projectBookings :: (Members '[State BookingsMemoryState] r) => AggregateId -> Events TourEvent -> Sem r ()
projectBookings aggregateId =
  mapM_ $
    \case
      TourCreated {..} ->
        modify $
          Map.insert aggregateId Tour {bookings = mempty, ..}
      BookingRequested {..} ->
        modify $
          Map.adjust
            (updateTourBookings $ Map.insert id Booking {accepted = False, ..})
            aggregateId
      BookingAccepted {..} ->
        modify $
          Map.adjust
            (updateTourBookings $ Map.adjust (\Booking {..} -> Booking {accepted = True, ..}) id)
            aggregateId

We can finally draft a small test, as follows:

spec :: Spec
spec =
  describe "Tours" $ do
    it "Booking projection" $
      let bookings =
            run $
              execState @BookingsMemoryState mempty $
                runEventSourceMemory @TourEvent $
                  withProjection projectBookings $ do
                    let aggregateId = AggregateId "0"
                    -- Command 0
                    withPlainEvents aggregateId $ \_ ->
                      return ([TourCreated "Eiffle Tower" (SpaceTime "In front" $ read "2026-08-18 09:00:00 +0200") (SpaceTime "In front" $ read "2026-08-18 10:00:00 +0200") 12], ())
                    -- Command 1
                    withPlainEvents aggregateId $ \_ ->
                      return ([BookingRequested (BookingRequestId "0") (PersonIdentity "Marvin") 3], ())
                    -- Command 2
                    withPlainEvents aggregateId $ \_ ->
                      return ([BookingAccepted (BookingRequestId "0")], ())
       in bookings
            `shouldBe` Map.singleton
              (AggregateId "0")
              Tour
                { name = "Eiffle Tower",
                  start = SpaceTime "In front" $ read "2026-08-18 09:00:00 +0200",
                  end = SpaceTime "In front" $ read "2026-08-18 10:00:00 +0200",
                  groupSize = 12,
                  bookings = Map.singleton (BookingRequestId "0") $ Booking (BookingRequestId "0") (PersonIdentity "Marvin") 3 True
                }

Having a projection as an interceptor gives a lot of modularity, but it comes at the cost of ordering consumers:

  • Start with interceptors (projections)
  • Continue with the interpreter (consuming the event store)
  • Consume the remaining effects