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)
Step 1: Events
Section titled “Step 1: Events”Events are pure domain Schema.TaggedClass definitions. Each event captures something that happened in the domain — no DynamoDB concepts:
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 | MatchEndedEach tag doubles as the event type discriminator stored in DynamoDB.
Step 2: Schema and Table
Section titled “Step 2: Schema and Table”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):
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 } })Step 3: Event Stream
Section titled “Step 3: Event Stream”An event stream binds events to a table, names the stream, and declares which attributes compose the stream ID (analogous to an aggregate ID):
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
Step 4: Decider
Section titled “Step 4: Decider”The decider is the core pattern for event sourcing. It defines three things:
- initialState — the starting state before any events
- decide — validates a command against current state, returning events or an error
- evolve — applies an event to state, producing the next state
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.
Step 5: Command Handler
Section titled “Step 5: Command Handler”EventStore.commandHandler wires the decider to an event stream. It handles the read-decide-append cycle with optimistic concurrency:
const matchEvents = yield* EventStore.bind(MatchEvents)const handleMatch = EventStore.commandHandler(matchDecider, matchEvents)Each call to handleMatch:
- Reads all events for the stream ID
- Folds them through
evolveto reconstruct current state - Runs
decidewith the command and current state - Appends the resulting events with an expected version check
Append limits and version contiguity
Section titled “Append limits and version contiguity”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 > 0the version-contiguityConditionCheck(below) occupies one more slot. An append that would exceed the limit fails upfront with a typedAppendTooLargeerror — 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 whenexpectedVersionis0, 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 theTRANSACT_WRITE_ITEMS_LIMITconstant. - 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
DynamoClientErrorfrom 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.
Step 6: Running Commands
Section titled “Step 6: Running Commands”Start Match
Section titled “Start Match”const r1 = yield* handleMatch( { matchId: "m-1" }, { _tag: "StartMatch", venue: "MCG", homeTeam: "AUS", awayTeam: "ENG" },)// State: in-progress, Version: 1, Events: 1The return value includes the new state, the version after append, and the events that were produced.
Complete Innings
Section titled “Complete Innings”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: 3End Match
Section titled “End Match”const r4 = yield* handleMatch( { matchId: "m-1" }, { _tag: "EndMatch", result: "AUS won by 70 runs" },)// State: completed, Result: AUS won by 70 runs, Version: 4Step 7: Reading Events
Section titled “Step 7: Reading Events”Read All Events
Section titled “Read All Events”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.
Read From a Specific Version
Section titled “Read From a Specific Version”Useful for catching up from a known checkpoint:
const laterEvents = yield* matchEvents.readFrom({ matchId: "m-1" }, 2)// v2: InningsCompleted// v3: InningsCompleted// v4: MatchEndedCurrent Version
Section titled “Current Version”const version = yield* matchEvents.currentVersion({ matchId: "m-1" })// Current version: 4Step 8: Fold and Query
Section titled “Step 8: Fold and Query”Reconstruct State from Events
Section titled “Reconstruct State from Events”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=2This is a pure function — no DynamoDB calls. It takes the events you already have and replays them through evolve.
Query Combinators
Section titled “Query Combinators”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 MatchEndedStep 9: Domain Error Handling
Section titled “Step 9: Domain Error Handling”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: AlreadyStartedBecause 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", () => ...), )Step 10: Snapshots
Section titled “Step 10: Snapshots”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:
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:
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.
Step 11: Retrying Version Conflicts
Section titled “Step 11: Retrying Version Conflicts”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:
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 },)Reading a snapshot
Section titled “Reading a snapshot”const snapshot = yield* snapshotMatchEvents.readSnapshot({ matchId: "m-2" })const asOfVersion = Option.match(snapshot, { onNone: () => 0, onSome: (s) => s.asOfVersion,})// → asOfVersion: 3readSnapshot 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" },)Writing a snapshot by hand
Section titled “Writing a snapshot by hand”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.
Step 12: Atomic Side Writes
Section titled “Step 12: Atomic Side Writes”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.
Bound-client builders work too
Section titled “Bound-client builders work too”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.
One additional item can become several
Section titled “One additional item can become several”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.
Telling the failures apart
Section titled “Telling the failures apart”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| Error | What failed | What to do |
|---|---|---|
VersionConflict | An event put’s guard — the stream advanced under you | Re-read, re-decide, retry |
AdditionalItemConditionFailed | One of your additionalItems conditions | Inspect indices (0-based into additionalItems); retrying blindly will not help |
DuplicateCommand | The idempotency sentinel — this commandId already ran | Nothing; the command already took effect |
TransactionCancelled | Throttling, a concurrent transaction, or a validation error | Retry with backoff |
AppendTooLarge | events + additionalItems + sentinel + contiguity check exceeded DynamoDB’s 100-item cap | Split 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.
Step 13: Command Idempotency
Section titled “Step 13: Command Idempotency”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: DuplicateCommandConfiguring 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
commandIduniqueness is scoped to the stream. It is invisible toread,readFrom, andcurrentVersion, 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.
ttlis optional and written to the table’s TTL attribute (honouringTableConfig.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 —
DuplicateCommandcarriesstreamName,streamId, andcommandId. 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.
Running the Example
Section titled “Running the Example”The complete runnable example is at examples/event-sourcing.ts in the repository.
docker run -d -p 8000:8000 amazon/dynamodb-localnpx tsx examples/event-sourcing.tsLayer Setup
Section titled “Layer Setup”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),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Event stream | EventStore.makeStream binds event schemas to a table with stream ID composites |
| Decider pattern | initialState + decide (command validation) + evolve (state transition) |
| Command handler | EventStore.commandHandler wires decider to stream with optimistic concurrency |
| Read operations | read (all events), readFrom (from version), currentVersion (latest version number) |
| Fold | EventStore.fold reconstructs state from events using the decider’s evolve function |
| Query combinators | Query.reverse, Query.limit, Query.collect work on event stream queries |
| Domain errors | Tagged errors (AlreadyStarted, NotStarted, AlreadyEnded) for precise error handling |
| Snapshots | snapshot: { schema, every } caches a fold of the stream; the handler replays only the delta |
| Snapshot primitives | readSnapshot / 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 writes | append(..., { additionalItems }) commits Entity.put / Entity.delete / bound db.entities.* builders / Transaction.check ops with the events |
| Position-aware errors | VersionConflict vs. AdditionalItemConditionFailed vs. DuplicateCommand — you always know whose condition failed |
| Command idempotency | At-least-once by default; commandHandler(..., { idempotency }) + a commandId makes it exactly-once via a dedup sentinel |
What’s Next?
Section titled “What’s Next?”- Modeling Guide — Deep dive into models, schemas, tables, and entities
- Queries Guide — Query combinators, pagination, and filtering
- Example: Unique Constraints — Globally unique fields and idempotency keys
- Example: Human Resources — Single-table design with collections and transactions