Command Palette

Search for a command to run...

Level 3 · 30 min

Event Sourcing

Event Sourcing stores the full history of state changes as an immutable sequence of events, rather than the current state only. This provides a complete audit log, temporal queries, and the ability to replay history to rebuild state in any past or alternative configuration.

Append-Only Event Log

In traditional persistence, you UPDATE and DELETE rows — current state is all you have. In Event Sourcing, state is derived by replaying an append-only log of immutable domain events. You never UPDATE or DELETE events — you only INSERT new events. Current state = apply all events from the beginning. Example: instead of storing account.balance = 500, you store: AccountOpened{balance: 1000}, MoneyWithdrawn{amount: 300}, MoneyDeposited{amount: 200}. Replay: 1000 - 300 + 200 = 900. Wait — the current state is 900 after replay; the scenario above had a bug I'll fix in the code example. The event log IS the source of truth. Any other representation (current state table, search index) is a derived projection. Event stores: EventStoreDB, Marten (PostgreSQL), Kafka (as a durable event log with infinite retention).

Snapshots and Replay

If an aggregate has thousands of events, replaying all of them on every load is slow. Snapshots solve this: periodically capture the current aggregate state as a snapshot (event + state at a point in time). To load: read the latest snapshot + events after the snapshot, replay only the delta. Snapshot frequency depends on event volume: every 50-100 events is common. Snapshots are an optimization, not the source of truth — they can be discarded and rebuilt from events at any time. The ability to replay all events from the beginning enables: temporal queries ('what was the account balance on January 1st?'), system migration ('rebuild the read model with a new projection logic'), and debugging ('what events caused the current anomalous state?'). Newman frames event-carried state transfer as a key microservices pattern: these events 'could be used to reconstitute an entity at given points in time.' The Kafka default message size limit (1MB) matters here — events containing large payloads should use event-carried references (store the document separately, put only the ID in the event) rather than embedding the full payload. When using Kafka as an event store, set retention to 'infinite' (log compaction or no TTL) for the source-of-truth event topics; use TTL-bounded topics only for derived projections. — Sam Newman, Building Microservices (2nd ed.)

Event Versioning and Schema Evolution

Events are immutable once written. When the event schema changes, you cannot modify old events — you must handle both old and new formats. Upcasting: when reading an old event, convert it to the new format in memory. Example: MoneyWithdrawn v1 had 'amount', v2 adds 'currency'. Upcast v1 events to v2 by setting currency='USD' as a default. Additive changes (adding optional fields): backward compatible, handled with default values. Breaking changes (renaming fields, changing types): require explicit upcast logic for all versions. Event versioning policy: use event type names that include a version (MoneyWithdrawnV1, MoneyWithdrawnV2) to make schema evolution explicit. Alternatively, use a schema registry (Avro, Protobuf) for schema evolution with compatibility checks.

Key Takeaways

  • Events are immutable facts about what happened, in past tense (OrderPlaced, MoneyWithdrawn, UserRegistered). They are never updated. Current state is derived by replaying events.
  • Snapshots are an optimization to avoid replaying long event streams. They are derived, not authoritative — always rebuildable from events.
  • Event versioning is the hardest operational challenge of Event Sourcing. Plan your upcasting strategy before going to production — changing event schemas later is expensive.

Code example

// Event sourced aggregate (TypeScript)\ntype AccountEvent =\n  | {type: "AccountOpened", initialBalance: number}\n  | {type: "MoneyDeposited", amount: number}\n  | {type: "MoneyWithdrawn", amount: number};\n\nfunction applyEvent(state: number, event: AccountEvent): number {\n  switch (event.type) {\n    case "AccountOpened": return event.initialBalance;\n    case "MoneyDeposited": return state + event.amount;\n    case "MoneyWithdrawn": return state - event.amount;\n  }\n}\n\n// Replay events to get current balance\nconst events: AccountEvent[] = loadEventsFromStore(accountId);\nconst balance = events.reduce(applyEvent, 0);