BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Pattern — Event Sourcing

The problem it solves

A read-modify-write that stores an absolute value silently loses concurrent updates: two operators both read balance 100, one writes 150, the other writes 70, and the later write clobbers the earlier — the +50 is gone with no error (Drill 70). Storing only current state means every write destroys the state it replaces, so a stale write wins by default and the history that would explain the drift no longer exists.

The pattern

Stop overwriting state; append immutable events to a log and derive state by folding over them. The event store holds one row per fact (credit +50, fee −30) — never updated, never deleted. Current balance is not a column you SET, it's a projection: a SUM (or fold) over the stream. Because appends only ever add rows, two concurrent corrections both survive regardless of order — there is no cell to clobber. The log is also the audit trail: every state the system was ever in is reconstructable by replaying events up to a point in time.

How to apply it

  1. Model changes as events, not assignments. Persist AccountCredited{amount: 50} and FeeCharged{amount: 30}, not balance = 70. Each event is an immutable row in an append-only table: (stream_id, version, event_type, payload_json, occurred_at), with a UNIQUE (stream_id, version) constraint.
  2. Append with an expected version (optimistic concurrency). A writer that read the stream at version 7 appends at version 8 via INSERT ... (stream_id, version=8, ...); the unique constraint makes a second writer's version=8 fail. On conflict, re-read and retry — bounded at, say, 3 attempts with jittered backoff. This guards conflicting decisions (two people editing the same known value) while commuting events (money deltas) still simply both land.
  3. Serve reads from a projection, not the log. Maintain a balances read model updated as events are appended, or a materialized view SELECT stream_id, SUM(signed_amount) FROM events GROUP BY stream_id. Rebuild it any time by replaying — the log is the source of truth, projections are disposable caches.
  4. Snapshot to bound replay cost. Replaying a 50,000-event stream on every read is O(n). Write a snapshot every N = 100 events ({version: 3400, balance: 8820}); a read loads the latest snapshot and folds only the ≤ 100 events after it. Tune N so worst-case fold stays under your read latency budget (e.g. < 5 ms).
  5. Keep events forever; version the schema. Events are immutable, so payloads accrete fields over years — carry a schema_version per event type and upcast old shapes on read. Retention is effectively infinite (that's the audit value); size projections and snapshots, not the log, for cost.
flowchart LR
    A["Command: credit +50"] --> B["Append event<br/>(stream, version=n+1)"]
    C["Command: charge -30"] --> D["Append event<br/>(stream, version=n+2)"]
    B --> E["Append-only event log<br/>(immutable, never overwritten)"]
    D --> E
    E --> F["Fold / SUM<br/>(+ latest snapshot)"]
    F --> G["Projection: balance = 120"]

When to use it — and when not

Reach for it when history is the product — money movements, ledgers, order lifecycles, anything audited or where "how did we get here?" is a real question, and where concurrent writers must not silently lose each other's work (Drill 70). It also unlocks temporal queries (balance as of any date) and rebuildable read models for free.

Skip it when the extra machinery isn't paid back. Pure CRUD with no audit need (a user's display-name field) is cheaper as a plain row — event sourcing adds a projection layer, eventual-consistency lag between append and read model, snapshot maintenance, and schema-upcasting forever. It is also not a fix for cross-aggregate transactions or for high-cardinality, low-value state. For a single additive counter, an atomic increment (SET balance = balance + :delta) gives you the same "no lost update" guarantee at a fraction of the cost; for edit-a-known-value with no history need, a version-checked compare-and-swap is enough. Event sourcing earns its keep when you want all of: no lost writes, full audit, and time travel.

Signals it's working

Seen in the wild

See it

The ChainScene shows two concurrent commands (+50, −30) both appending to one immutable log and folding to 120 — nothing overwritten; CardsScene and TilesScene carry the load-bearing config (append with expected version, UNIQUE (stream_id, version), snapshot every 100 events, 3-attempt retry, 0 UPDATEs on the log).

Named concepts: event sourcing, append-only event log, projections, snapshots.