Design & Internals

This page goes one layer below the core concepts: how the event store is structured, what guarantees it provides, and why it’s built this way. Each decision is a tradeoff made explicit in the types rather than hidden behind a “smart” default.

One table, two orderings

An event store serves two access patterns. Per-aggregate reads fetch one entity’s events in order (how command handlers rebuild state). Global reads fetch every event in total order (how read models catch up).

The naive approach uses two tables — but dual-writing risks the per-aggregate insert succeeding while the global-log insert fails. Eventium uses a single table instead:

Column Purpose
id (PK, auto-increment) global sequence number
uuid aggregate / stream identifier
version position within the stream
payload serialized event (JSON)
metadata event type, correlation/causation IDs, timestamps, custom tags

A unique constraint on (uuid, version) enforces per-stream ordering; the auto-increment key provides global ordering. Per-aggregate reads filter by uuid order by version; global reads order by id. Simpler, and no dual-write consistency problem.

Gap-free global sequences

Auto-increment IDs don’t give you safe total ordering under concurrency. If transaction A gets id 1 and B gets id 2, but B commits first, a subscriber can see event 2, advance its checkpoint past it, and then never see event 1 once A commits. That’s silent data loss in any checkpoint-based consumer.

Eventium closes the gap at the backend, keeping the same interface:

Both expose the same EventStoreWriter. The guarantee is a property of the backend, not the abstraction — you pick a backend at the application boundary and the domain code is unchanged.

Optimistic concurrency

Concurrency control uses versions, not locks. A command handler captures the stream’s version when it reads and asserts it hasn’t changed when it writes, via ExpectedPosition. No lock is held during business logic; conflicts are detected on write and returned as a typed ConcurrencyConflict value. Retry strategy is left to the caller — it’s domain-specific.

Records, not typeclasses

Most Haskell libraries reach for a typeclass to make backends pluggable. Eventium makes the store abstractions plain records instead:

newtype EventStoreReader key position m event =
  EventStoreReader {getEvents :: QueryRange key position -> m [event]}

newtype EventStoreWriter key position m event =
  EventStoreWriter
    {storeEvents :: key -> ExpectedPosition position -> [event]
                 -> m (Either (EventWriteError position) EventWriteResult)}

Because stores are values, you can have two of the same kind in one program (a main table and an archive table), pass them explicitly, and never hit orphan instances. Composition is function application at the boundary — runEventStoreReaderUsing lifts a reader across monads with a natural transformation:

runEventStoreReaderUsing ::
  (forall a. mstore a -> m a) ->
  EventStoreReader key position mstore event ->
  EventStoreReader key position m event

The same reasoning applies to Codec, ProjectionCache, CheckpointStore, and CommandDispatcher — all records, all composed as values. You see the configuration at the call site instead of having the compiler resolve it invisibly.

The write result carries real positions back to the caller:

type EventWriteResult = [(EventVersion, SequenceNumber)]

one (version, global sequence) pair per event written, in order.

Metadata as a pipeline

Domain events describe the business (AccountOpened); infrastructure concerns — type name, timestamps, correlation/causation IDs — live separately on EventMetadata, not on the domain event:

data EventMetadata = EventMetadata
  { eventType :: !EventTypeName,
    correlationId :: !(Maybe UUID),
    causationId :: !(Maybe UUID),
    createdAt :: !(Maybe UTCTime),
    custom :: !(Map Text Text)
  }

eventType is derived automatically from the Haskell type via Typeable; the custom map holds arbitrary tags you want to attach. Enrichment is a composable function:

type MetadataEnricher = EventMetadata -> EventMetadata

Compose enrichers with ., use id for none. The enricher threads through the whole command pipeline — CommandDispatcher and ProcessManagerEffect both carry one — so a process manager can pass a correlation ID from a triggering event into the commands it issues.

Snapshotting long streams

Replaying thousands of events per command is wasteful for long-lived aggregates. ProjectionCache stores a snapshot of projected state at a version and only replays events since then:

data ProjectionCache key position encoded m = ProjectionCache
  { storeSnapshot :: key -> position -> encoded -> m (),
    loadSnapshot :: key -> m (Maybe (position, encoded))
  }

Because streams are append-only, a snapshot at version N is always valid — there’s no invalidation problem, only replay-forward. Wire it in with applyCommandHandlerWithCache. Not every aggregate needs it; it pays off for high-throughput streams with long histories, at the cost of needing a serialization instance for aggregate state.

Event publishing

publishingEventStoreWriter wraps a store writer so events are dispatched to process managers and read models after a successful write. Build a publisher from an EventHandler with synchronousPublisher, and the writer handles the rest — when a command appends events, downstream consumers see them without manual plumbing.

Codec vs TypeEmbedding

Two conversions look structurally identical but mean different things:

data Codec a b        = Codec        {encode :: a -> b, decode  :: b -> Maybe a}
data TypeEmbedding a b = TypeEmbedding {embed  :: a -> b, extract :: b -> Maybe a}

Keeping them distinct means the compiler catches using one where you meant the other. Both are values (no orphan instances), and Template Haskell generates them — mkSumTypeCodec for wire codecs, mkSumTypeEmbedding for embeddings. embeddingToCodec bridges them when you genuinely need to.

Beyond the core

Two subsystems extend the store without changing the model above:

See it in practice

The Bank example wires most of this together: multiple aggregates, a process manager with compensation, event publishing, and both in-memory and persisted read models.