Skip to content

Example: Event Sourcing

An event-sourced cricket match tracker using effect-dynamodb’s EventStore module. Events are appended to a DynamoDB stream, state is reconstructed by folding over the event history, and commands are validated against current state before producing new events.

What you’ll learn:

  • Defining event streams with EventStore.makeStream
  • The decider pattern: commands, events, and state evolution
  • Command handlers with optimistic concurrency
  • Reading events (read, readFrom, currentVersion)
  • Reconstructing state with EventStore.fold
  • Query combinators on event streams (reverse, limit)

Events are pure domain Schema.TaggedClass definitions. Each event captures something that happened in the domain — no DynamoDB concepts:

events.ts
class MatchStarted extends Schema.TaggedClass<MatchStarted>()("MatchStarted", {
venue: Schema.String,
homeTeam: Schema.String,
awayTeam: Schema.String,
}) {}
class InningsCompleted extends Schema.TaggedClass<InningsCompleted>()("InningsCompleted", {
innings: Schema.Number,
runs: Schema.Number,
wickets: Schema.Number,
}) {}
class MatchEnded extends Schema.TaggedClass<MatchEnded>()("MatchEnded", {
result: Schema.String,
}) {}
type MatchEvent = MatchStarted | InningsCompleted | MatchEnded

Each tag doubles as the event type discriminator stored in DynamoDB.


Define the application schema and a table for the event store. The stream itself manages the event items — no entity registration is needed for them. Entities are only registered here so that ordinary records can be written atomically alongside events (see Step 10):

infrastructure.ts
const AppSchema = DynamoSchema.make({ name: "cricket", version: 1 })
// A per-writer ingestion watermark — a side record updated atomically with
// events via `append({ additionalItems })`.
class Watermark extends Schema.Class<Watermark>("Watermark")({
writerId: Schema.String,
lastSeq: Schema.Number,
}) {}
const Watermarks = Entity.make({
model: Watermark,
entityType: "Watermark",
primaryKey: {
pk: { field: "pk", composite: ["writerId"] },
sk: { field: "sk", composite: [] },
},
})
// A read model kept in step with the stream. Authored with the pure, AWS-free
// `@effect-dynamodb/schema` package — it carries no CRUD ops, so its writes are
// always built from the bound client (`db.entities.MatchStatus.put(...)`).
const MatchStatusRecord = Schema.Struct({
matchId: Schema.String,
status: Schema.String,
})
const MatchStatus = PureEntity.make({
model: DynamoModel.configure(MatchStatusRecord, { matchId: { identifier: true } }),
entityType: "MatchStatus",
primaryKey: {
pk: { field: "pk", composite: ["matchId"] },
sk: { field: "sk", composite: [] },
},
})
const EventsTable = Table.make({ schema: AppSchema, entities: { Watermarks, MatchStatus } })

An event stream binds events to a table, names the stream, and declares which attributes compose the stream ID (analogous to an aggregate ID):

stream.ts
const MatchEvents = EventStore.makeStream({
table: EventsTable,
streamName: "Match",
events: [MatchStarted, InningsCompleted, MatchEnded],
streamId: { composite: ["matchId"] },
})

Under the hood, each event is stored as a DynamoDB item with:

  • PK composed from the stream ID (matchId)
  • SK containing a zero-padded version number for ordered retrieval
  • eventType discriminator for decoding back to the correct Schema.Class

The decider is the core pattern for event sourcing. It defines three things:

  1. initialState — the starting state before any events
  2. decide — validates a command against current state, returning events or an error
  3. evolve — applies an event to state, producing the next state
decider.ts
interface MatchState {
readonly status: "pending" | "in-progress" | "completed"
readonly venue?: string
readonly innings: ReadonlyArray<{ runs: number; wickets: number }>
readonly result?: string
}
type MatchCommand =
| {
readonly _tag: "StartMatch"
readonly venue: string
readonly homeTeam: string
readonly awayTeam: string
}
| {
readonly _tag: "CompleteInnings"
readonly innings: number
readonly runs: number
readonly wickets: number
}
| { readonly _tag: "EndMatch"; readonly result: string }
class AlreadyStarted extends Data.TaggedError("AlreadyStarted") {}
class NotStarted extends Data.TaggedError("NotStarted") {}
class AlreadyEnded extends Data.TaggedError("AlreadyEnded") {}
const matchDecider: EventStore.Decider<
MatchState,
MatchCommand,
MatchEvent,
AlreadyStarted | NotStarted | AlreadyEnded
> = {
initialState: { status: "pending", innings: [] },
decide: (command, state) =>
Effect.gen(function* () {
if (command._tag === "StartMatch") {
if (state.status !== "pending") return yield* new AlreadyStarted()
return [
new MatchStarted({
venue: command.venue,
homeTeam: command.homeTeam,
awayTeam: command.awayTeam,
}),
]
}
if (command._tag === "CompleteInnings") {
if (state.status !== "in-progress") return yield* new NotStarted()
return [
new InningsCompleted({
innings: command.innings,
runs: command.runs,
wickets: command.wickets,
}),
]
}
if (command._tag === "EndMatch") {
if (state.status === "completed") return yield* new AlreadyEnded()
if (state.status !== "in-progress") return yield* new NotStarted()
return [new MatchEnded({ result: command.result })]
}
return []
}),
evolve: (state, event) => {
if (event instanceof MatchStarted) {
return { ...state, status: "in-progress" as const, venue: event.venue }
}
if (event instanceof InningsCompleted) {
return {
...state,
innings: [...state.innings, { runs: event.runs, wickets: event.wickets }],
}
}
if (event instanceof MatchEnded) {
return { ...state, status: "completed" as const, result: event.result }
}
return state
},
}

The decide function is effectful — it can fail with domain errors. The evolve function is pure — it simply transforms state. This separation keeps business rules in decide and state transitions in evolve.


EventStore.commandHandler wires the decider to an event stream. It handles the read-decide-append cycle with optimistic concurrency:

handler.ts
const matchEvents = yield* EventStore.bind(MatchEvents)
const handleMatch = EventStore.commandHandler(matchDecider, matchEvents)

Each call to handleMatch:

  1. Reads all events for the stream ID
  2. Folds them through evolve to reconstruct current state
  3. Runs decide with the command and current state
  4. Appends the resulting events with an expected version check

append writes every event in a single TransactWriteItems request, so an append is all-or-nothing — either every event in the batch is persisted or none is. That atomicity is bounded by DynamoDB’s transaction limits:

  • 100 items per transaction. Each event is one transact item, and when expectedVersion > 0 the version-contiguity ConditionCheck (below) occupies one more slot. An append that would exceed the limit fails upfront with a typed AppendTooLarge error — before any request is issued. The batch is deliberately never chunked into multiple transactions, because chunking would break append atomicity. In practice a single append holds at most 100 events when expectedVersion is 0, and at most 99 otherwise. If you hit this, split the work into smaller commands (separate appends at their own expected versions). The limit is exported as the TRANSACT_WRITE_ITEMS_LIMIT constant.
  • 4MB total payload per transaction. This cap is not pre-validated — the marshalled request size is not practical to compute upfront — so exceeding it surfaces as a DynamoClientError from AWS. Keep event payloads small; large blobs belong in S3 with a reference in the event.

append also enforces that the appended range is contiguous with the stream head. Every event Put is conditioned on attribute_not_exists(pk), which rejects stale expected versions (the target version slot already exists). In addition, when expectedVersion > 0 the transaction carries a ConditionCheck requiring the event at exactly expectedVersion to exist — so an ahead expected version (say expectedVersion: 10 on a stream whose head is at version 3) fails with VersionConflict instead of silently writing version 11 and leaving a permanent hole in the sequence. Both failure modes surface as the same VersionConflict error.


const r1 = yield* handleMatch(
{ matchId: "m-1" },
{ _tag: "StartMatch", venue: "MCG", homeTeam: "AUS", awayTeam: "ENG" },
)
// State: in-progress, Version: 1, Events: 1

The return value includes the new state, the version after append, and the events that were produced.

const r2 = yield* handleMatch(
{ matchId: "m-1" },
{ _tag: "CompleteInnings", innings: 1, runs: 250, wickets: 10 },
)
const r3 = yield* handleMatch(
{ matchId: "m-1" },
{ _tag: "CompleteInnings", innings: 2, runs: 180, wickets: 10 },
)
// State: in-progress, Innings: 2, Version: 3
const r4 = yield* handleMatch(
{ matchId: "m-1" },
{ _tag: "EndMatch", result: "AUS won by 70 runs" },
)
// State: completed, Result: AUS won by 70 runs, Version: 4

const allEvents = yield* matchEvents.read({ matchId: "m-1" })
// v1: MatchStarted at 2026-03-15T...
// v2: InningsCompleted at 2026-03-15T...
// v3: InningsCompleted at 2026-03-15T...
// v4: MatchEnded at 2026-03-15T...

Each event envelope includes version, eventType, timestamp, and the decoded event payload.

Useful for catching up from a known checkpoint:

const laterEvents = yield* matchEvents.readFrom({ matchId: "m-1" }, 2)
// v2: InningsCompleted
// v3: InningsCompleted
// v4: MatchEnded
const version = yield* matchEvents.currentVersion({ matchId: "m-1" })
// Current version: 4

EventStore.fold applies the decider’s evolve function over a list of events, starting from initialState:

const state = EventStore.fold(matchDecider, allEvents)
// Reconstructed: status=completed, innings=2

This is a pure function — no DynamoDB calls. It takes the events you already have and replays them through evolve.

Event streams expose a query API with the same combinators as entity queries. For example, get the latest event using reverse and limit:

const latest = yield* matchEvents.provide(
matchEvents.query.events({ matchId: "m-1" }).pipe(Query.reverse, Query.limit(1), Query.collect),
)
const [latestEvent] = latest
// Latest: v4 MatchEnded

The decider enforces business rules. Sending an invalid command produces a tagged error:

const error = yield* handleMatch(
{ matchId: "m-1" },
{ _tag: "StartMatch", venue: "SCG", homeTeam: "AUS", awayTeam: "IND" },
).pipe(Effect.flip)
// Error: AlreadyStarted

Because errors use Data.TaggedError, you can handle specific cases with Effect.catchTag:

yield* handleMatch(streamId, command)
.pipe(
Effect.catchTag("AlreadyStarted", () => ...),
Effect.catchTag("NotStarted", () => ...),
Effect.catchTag("AlreadyEnded", () => ...),
)

Every command so far replayed the entire stream before deciding. That is fine for short-lived streams and a scaling cliff for long-lived ones. A snapshot is a cached fold of the stream up to some version: the handler reads it, replays only the events after it, and decides from there.

Snapshots are opt-in. Declare a schema for your state — the snapshot round-trips through it, so branded types, DateTime fields, and other transforming schemas all work:

state.ts
const MatchStateSchema = Schema.Struct({
status: Schema.Literals(["pending", "in-progress", "completed"]),
venue: Schema.optionalKey(Schema.String),
innings: Schema.Array(Schema.Struct({ runs: Schema.Number, wickets: Schema.Number })),
result: Schema.optionalKey(Schema.String),
})

Then pass it to makeStream. every: N asks the command handler to write a fresh snapshot after a successful append, once at least N events have accumulated since the last one:

stream.ts
const SnapshotMatchEvents = EventStore.makeStream({
table: EventsTable,
streamName: "SnapshotMatch",
events: [MatchStarted, InningsCompleted, MatchEnded],
streamId: { composite: ["matchId"] },
snapshot: { schema: MatchStateSchema, every: 3 },
})

The snapshot is stored as a single item in the stream’s own partition, under a sort key that can never collide with an event sort key ($cricket#v1#snapshotmatch.snapshot). It is invisible to read, readFrom, currentVersion, and query.events, and it is overwritten in place — snapshots are a cache, never history. The event stream stays the source of truth, so a snapshot can always be deleted and rebuilt.


Two concurrent commands on the same stream will race: one wins the optimistic-concurrency check and the other fails with VersionConflict. The correct recovery is not to re-append the events you already decided — those were decided against stale state. It is to re-read, re-decide, and re-append.

That is exactly what the retry option does. Pass a max-attempts number or an Effect Schedule; only VersionConflict is retried, and the retried unit is the whole read-decide-append cycle:

handler.ts
const snapshotMatchEvents = yield* EventStore.bind(SnapshotMatchEvents)
const handleSnapshotMatch = EventStore.commandHandler(matchDecider, snapshotMatchEvents, {
retry: 3,
})
// A Schedule works too — exponential backoff, capped at 5 retries:
EventStore.commandHandler(matchDecider, snapshotMatchEvents, {
retry: Schedule.exponential("50 millis").pipe(Schedule.compose(Schedule.recurs(5))),
})

Domain errors from your decider and infrastructure errors are never retried — they fail immediately. The default is no retry.

Commands run exactly as before. The third one crosses the every: 3 threshold, so a snapshot is written after the append:

yield* handleSnapshotMatch(
{ matchId: "m-2" },
{ _tag: "StartMatch", venue: "SCG", homeTeam: "AUS", awayTeam: "IND" },
)
yield* handleSnapshotMatch(
{ matchId: "m-2" },
{ _tag: "CompleteInnings", innings: 1, runs: 310, wickets: 8 },
)
// The third event crosses the `every: 3` threshold — a snapshot is written.
const s3 = yield* handleSnapshotMatch(
{ matchId: "m-2" },
{ _tag: "CompleteInnings", innings: 2, runs: 275, wickets: 10 },
)
const snapshot = yield* snapshotMatchEvents.readSnapshot({ matchId: "m-2" })
const asOfVersion = Option.match(snapshot, {
onNone: () => 0,
onSome: (s) => s.asOfVersion,
})
// → asOfVersion: 3

readSnapshot returns Option<Snapshot<State>>, where Snapshot carries the decoded state, the asOfVersion it reflects, and a timestamp.

The next command folds from that snapshot plus the single event after it, rather than replaying all four — and produces exactly the same state:

const s4 = yield* handleSnapshotMatch(
{ matchId: "m-2" },
{ _tag: "EndMatch", result: "AUS won by 35 runs" },
)

Omit every to keep the cadence under your own control — for example from a backfill job — and call writeSnapshot yourself:

const events = yield* snapshotMatchEvents.read({ matchId: "m-2" })
const folded = EventStore.fold(matchDecider, events)
yield* snapshotMatchEvents.writeSnapshot({ matchId: "m-2" }, folded, s4.version)

Snapshot writes are monotonic: if a newer snapshot already exists, the write is a successful no-op rather than a regression of the cache. Auto-snapshot writes are best-effort — a failure is logged and never fails the command, because the events it summarises are already durable.

Real event-sourced systems usually need one non-event record kept in step with the events: an ingestion watermark, a read model, a stream registry row, a uniqueness guard. append accepts additionalItems — the same operation builders Transaction.transactWrite takes (Entity.put, Entity.delete, and Transaction.check) — and commits them in the same TransactWriteItems call as the events:

yield* matchEvents.append(
{ matchId: "m-2" },
[new MatchStarted({ venue: "SCG", homeTeam: "AUS", awayTeam: "IND" })],
0,
{
additionalItems: [Watermarks.put({ writerId: "ingest-1", lastSeq: 4021 })],
},
)

Either everything lands or nothing does. The event puts, your items, and the version guard are one atomic unit.

The bound builders returned by db.entities.* (put, create, delete, deleteIfExists) are accepted anywhere an Entity.put / Entity.delete is. That matters most for entities authored with the pure, AWS-free @effect-dynamodb/schema package: a pure EntityDefinition carries no operations, so the bound builder is the only write descriptor its author can hold — and it is exactly what you need to commit a read model atomically with the events that produced it:

const db = yield* DynamoClient.make({ entities: { MatchStatus }, tables: { EventsTable } })
yield* matchEvents.append(
{ matchId: "m-4" },
[new MatchStarted({ venue: "Basin Reserve", homeTeam: "NZL", awayTeam: "SAF" })],
0,
{
additionalItems: [db.entities.MatchStatus.put({ matchId: "m-4", status: "in-progress" })],
},
)

Conditions ride along: .condition({ ... }) on a bound put or delete, and the implicit attribute_not_exists guard create() carries, are compiled into the transact item. A failing one surfaces as AdditionalItemConditionFailed, not a version conflict.

A put of an entity with unique constraints or versioned: { retain: true } expands: the row, one guarded uniqueness sentinel per satisfiable constraint, and the v1 version snapshot — all committed in the same transaction as the events. Two things follow.

AdditionalItemConditionFailed.indices still means indices into the array you passed. A failing sentinel is reported against the op that produced it, not against the raw transaction position, and one op that fails in several places is reported once.

The 100-item cap counts the expanded total, so events + expanded additional items + sentinel + contiguity check must fit. AppendTooLarge.count reports the expanded number.

Because your items carry their own conditions, “the transaction was cancelled” is no longer a single story. append maps cancellation reasons by position, so you always know whose condition failed:

const condError = yield* matchEvents
.append({ matchId: "m-2" }, [new InningsCompleted({ innings: 1, runs: 300, wickets: 8 })], 1, {
additionalItems: [
Transaction.check(
Watermarks.get({ writerId: "ingest-1" }),
Expression.condition({ lt: { lastSeq: 100 } }),
),
],
})
.pipe(Effect.flip)
// Error: AdditionalItemConditionFailed
ErrorWhat failedWhat to do
VersionConflictAn event put’s guard — the stream advanced under youRe-read, re-decide, retry
AdditionalItemConditionFailedOne of your additionalItems conditionsInspect indices (0-based into additionalItems); retrying blindly will not help
DuplicateCommandThe idempotency sentinel — this commandId already ranNothing; the command already took effect
TransactionCancelledThrottling, a concurrent transaction, or a validation errorRetry with backoff
AppendTooLargeevents + additionalItems + sentinel + contiguity check exceeded DynamoDB’s 100-item capSplit the append

Without position-aware mapping, a failed watermark condition would be reported as a VersionConflict — and the caller’s natural response (re-read, re-decide, retry) would loop forever against a condition that can never pass.


By default, command processing is at-least-once. If a caller’s response is lost after the append committed, the retry re-reads the stream, re-runs decide, and appends again. Deciders that are self-guarding (StartMatch fails once the match is started) absorb this; deciders that aren’t (CompleteInnings happily emits another event) do not.

To get exactly-once processing, configure idempotency on the handler and pass a commandId per call. The handler writes a dedup sentinel guarded by attribute_not_exists into the same transaction as the events:

const handleIdempotent = EventStore.commandHandler(matchDecider, matchEvents, {
idempotency: { ttl: Duration.days(1) },
})
yield* handleIdempotent(
{ matchId: "m-3" },
{ _tag: "StartMatch", venue: "Lords", homeTeam: "ENG", awayTeam: "NZ" },
{ commandId: "cmd-7f3a" },
)
// CompleteInnings is not self-guarding — the decider happily produces a second
// event, so only the dedup sentinel can catch the replay.
yield* handleIdempotent(
{ matchId: "m-3" },
{ _tag: "CompleteInnings", innings: 1, runs: 210, wickets: 6 },
{ commandId: "cmd-9b12" },
)
const dupError = yield* handleIdempotent(
{ matchId: "m-3" },
{ _tag: "CompleteInnings", innings: 1, runs: 210, wickets: 6 },
{ commandId: "cmd-9b12" },
).pipe(Effect.flip)
// Error: DuplicateCommand

Configuring idempotency makes commandId required at the type level — you cannot silently fall back to at-least-once.

What to know about the sentinel:

  • It lives in the stream’s own partition, so commandId uniqueness is scoped to the stream. It is invisible to read, readFrom, and currentVersion, which filter on the event entity type.
  • Its sort key is composed with the schema’s casing (lowercase by default), so command ids that differ only in case collide. Use UUIDs or ULIDs.
  • ttl is optional and written to the table’s TTL attribute (honouring TableConfig.ttlAttributeName). Set it to the longest window over which your infrastructure can replay a command; omit it and sentinels are permanent.
  • A replay is rejected, not replayed — DuplicateCommand carries streamName, streamId, and commandId. If you need the original outcome, catch the error and re-read the stream.
  • A command that produces no events writes no sentinel, because there is no transaction to attach it to.

The complete runnable example is at examples/event-sourcing.ts in the repository.

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local
Terminal window
npx tsx examples/event-sourcing.ts
main.ts
const AppLayer = Layer.mergeAll(
DynamoClient.layer({
region: "us-east-1",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
}),
EventsTable.layer({ name: "event-sourcing-example" }),
)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then(
() => console.log("\nDone."),
(err) => console.error("Failed:", err),
)

ConceptHow it’s used
Event streamEventStore.makeStream binds event schemas to a table with stream ID composites
Decider patterninitialState + decide (command validation) + evolve (state transition)
Command handlerEventStore.commandHandler wires decider to stream with optimistic concurrency
Read operationsread (all events), readFrom (from version), currentVersion (latest version number)
FoldEventStore.fold reconstructs state from events using the decider’s evolve function
Query combinatorsQuery.reverse, Query.limit, Query.collect work on event stream queries
Domain errorsTagged errors (AlreadyStarted, NotStarted, AlreadyEnded) for precise error handling
Snapshotssnapshot: { schema, every } caches a fold of the stream; the handler replays only the delta
Snapshot primitivesreadSnapshot / writeSnapshot for manual cadence; monotonic writes, never a cache regression
Retry{ retry } re-runs the full read-decide-append cycle on VersionConflict only
Atomic side writesappend(..., { additionalItems }) commits Entity.put / Entity.delete / bound db.entities.* builders / Transaction.check ops with the events
Position-aware errorsVersionConflict vs. AdditionalItemConditionFailed vs. DuplicateCommand — you always know whose condition failed
Command idempotencyAt-least-once by default; commandHandler(..., { idempotency }) + a commandId makes it exactly-once via a dedup sentinel