DrawLintDrawLint.ai
🗺️Design Patterns·5 min read

Event Sourcing

Store the stream of events as the source of truth and derive current state by replaying.

Event sourcing stores the append-only stream of domain events as the source of truth. Instead of saving only "account balance = $50", the system saves the sequence "AccountOpened", "Deposited $100", "Withdrawn $30", and "Withdrawn $20". Current state is a derived view produced by replaying the events in order.

🔭Think of it like…
A normal database table is the scoreboard. Event sourcing is the game tape. The scoreboard tells you the current score; the tape lets you replay every move, explain how the score changed, review a disputed call, and build new statistics later.

The problem: current state overwrites the story

Current-state systems are easy to query, but every update destroys context unless you build separate audit tables. If a booking changed from PENDING to CONFIRMED toCANCELLED, the final row may not tell you who changed it, which payment event arrived first, or what the system believed at 10:05. Event sourcing makes the history the primary data.

ModelSource of truthStrengthCost
Current-state CRUDLatest row valuesSimple queries and updatesHistory must be bolted on separately
Audit tablesLatest row plus change logGood compliance trailCan drift from domain events and often lacks replay semantics
Event sourcingAppend-only event streamAudit, replay, time travel, rebuildable projectionsMore design and operational complexity
The core idea
Facts are immutable. Append what happened, never mutate the fact that it happened. Build today's convenient read views from those facts.

Event streams: append-only facts per aggregate

Events are usually grouped by aggregate: one account, order, booking, cart, or document. Each stream has a monotonically increasing version. Commands validate against the current aggregate state, then append one or more new events if the command is allowed.

event log and replay
account-42 event stream:
  v1 AccountOpened     { ownerId: "u1", currency: "USD" }
  v2 MoneyDeposited    { amountCents: 10000 }
  v3 MoneyWithdrawn    { amountCents: 3000 }
  v4 MoneyWithdrawn    { amountCents: 2000 }

replay(events):
  state = { opened: false, balanceCents: 0 }
  for event in events ordered by version:
    if event.type == "AccountOpened":
      state.opened = true
      state.currency = event.currency
    if event.type == "MoneyDeposited":
      state.balanceCents += event.amountCents
    if event.type == "MoneyWithdrawn":
      state.balanceCents -= event.amountCents
  return state

result: { opened: true, balanceCents: 5000, currency: "USD" }

Optimistic concurrency

Appending usually includes an expected version: "appendMoneyWithdrawnonly if the stream is still at version 4". If another writer appended version 5 first, the command reloads, checks business rules again, and retries or rejects. This protects invariants without locking the entire system.

Snapshots: replay faster without changing the truth

Replaying ten events is cheap. Replaying ten million events for a hot aggregate on every request is not. A snapshot stores the derived state at a particular event version. To load the aggregate, read the latest snapshot and replay only events after it.

snapshot load path
snapshot:
  aggregateId: account-42
  version: 100000
  state: { balanceCents: 918273, status: "OPEN" }

loadAggregate(account-42):
  snapshot = readLatestSnapshot(account-42)
  events = readEventsAfter(account-42, snapshot.version)
  return replayFrom(snapshot.state, events)
  • Snapshot every N events: simple and predictable, common for aggregates with long histories.
  • Snapshot by cost: create one when replay time exceeds a threshold rather than every fixed count.
  • Snapshot is cache, not truth: if a snapshot is corrupted, you can rebuild it from the event stream.

CQRS and read models: write facts, query projections

Event-sourced write models are not optimized for arbitrary queries like "show the last 50 orders for this customer". The common pairing isCQRS: commands append events to the write model, and asynchronous projectors build read models tailored to screens, search, analytics, and APIs.

event sourcing with CQRS projections
command API:
  CancelOrder(orderId)
    → validate by replaying order stream
    → append OrderCancelled v12

projectors consume events:
  OrderCancelled
    → update orders_by_customer table
    → remove shipment task
    → update support dashboard
    → publish integration event to Kafka

Those projection updates are often delivered through a log such as Kafka or through an outbox/CDC pipeline. Read models can lag behind the write stream, so user experience must handle eventual consistency with loading states or read-your-writes shortcuts.

Projection rebuilds are a superpower
Need a new analytics table? Start a new projector at event 1 and replay the log. You can build new views from old facts without changing the write path.

Benefits: audit, time travel, and debugging

Event sourcing shines where the history is valuable, not just the final value. Financial ledgers, booking systems, inventory movements, workflow engines, source control, collaboration logs, and payment systems all benefit from a trustworthy sequence of facts.

  • Audit: every decision can point to the events that caused it, including who initiated commands and when.
  • Time travel: replay up to event version 123 to answer what the system believed at that moment.
  • Debugging: copy one aggregate stream into a test and reproduce a bug exactly.
  • Integration: downstream services can consume the same domain events that created the state.

Downsides: schema evolution and operational complexity

Event sourcing is a commitment. Events are long-lived APIs to your own future code. You cannot casually rename fields or reinterpret old facts without migration or upcasting. Teams also have to operate projectors, handle duplicate delivery, monitor lag, and explain eventual consistency to product owners.

GotchaWhy it mattersCommon mitigation
Event schema evolutionOld events must still replay years laterVersion events and use upcasters
Projection lagRead model may trail the write streamExpose pending states and monitor consumer lag
IdempotencyProjectors may process the same event more than onceStore last processed event id per projector
Privacy deletionImmutable logs conflict with erasure requirementsEncrypt PII separately or store references that can be scrubbed
OveruseCRUD domains become unnecessarily hardUse it only where history has product or compliance value
Do not event-source everything by default
A product catalog description or feature flag may not need replayable history. Event sourcing earns its keep when audit, ordering, rebuilding, and temporal questions are central to the domain.
Key takeaways
  • Event sourcing stores immutable domain events as the source of truth; current state is derived by replaying them in order.
  • Snapshots speed up loading long streams but remain rebuildable cache, not the authoritative record.
  • CQRS pairs naturally with event sourcing: append events on the write side and build query-optimized read models asynchronously.
  • The biggest benefits are audit, time travel, reproducible debugging, and rebuilding new projections from old facts.
  • The biggest costs are schema evolution, projection lag, idempotent consumers, privacy handling, and added mental model complexity.
The event stream is the source of truth. The account row, balance cache, or read model is derived by replaying events and can be rebuilt if needed.
A snapshot is only a cached checkpoint of derived state at a known event version. The underlying events still define truth, and the snapshot can be discarded and rebuilt from the stream.
Old events may need to replay forever. Future code must understand past event shapes, so teams version events, write upcasters, and avoid changing the meaning of historical facts.
Finished this lesson?

Mark it complete to track your progress through the workbook.