awesome-everything RU
↑ Back to the climb

Data Engineering

Event sourcing: the append-only log as source of truth

Crux Store the immutable stream of state-changing events, not the current state — current state is a left-fold over the log. You buy audit, time-travel, and replay; you pay in versioning, GDPR, snapshots, and eventual consistency.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 17 min

A customer disputes a charge: “I never set my plan to annual.” Support pulls the row — plan: annual — and shrugs. But the accounts table only holds the current value; the UPDATE that set it overwrote whatever was there before, and the application logs rotated out two weeks ago. Nobody can say what the plan was on the 14th, who changed it, or whether a buggy webhook did it. In an event-sourced system this is a thirty-second query: replay the stream up to that timestamp and read the answer. The difference is not a better log — it’s that the log is the database.

State is a left-fold over events

A normal CRUD table stores the latest snapshot and destroys history on every UPDATE. Event sourcing inverts this: the append-only event store holds an ordered, immutable sequence of facts — AccountOpened, PlanChanged, CardDeclined — and the current state is just what you get by folding a reducer over them from the beginning. state = events.reduce(apply, initial). The events are the source of truth; the “current state” is a derived cache you can throw away and recompute at any time.

That single inversion is where every benefit and every cost comes from. You get a complete audit trail for free (the log is the audit). You get temporal queries — state as-of any past instant, by folding only the events before that timestamp. You get debugging by replay: copy the production stream into staging, run it through the new code, and watch the exact bug reproduce deterministically. None of this is bolted on; it falls out of never throwing data away.

The append-only constraint is load-bearing. You never UPDATE or DELETE an event. A mistake is corrected by appending a compensating event (PlanCorrected), the same way an accountant never erases a ledger entry — they post a reversing one. This is why event stores optimize for one thing: fast appends and fast sequential reads of a stream.

Event sourcing is not “Kafka” and not a change log

This is the distinction seniors get wrong most often. A change-data-capture log or a plain Kafka topic records events, but that alone is not event sourcing. The defining property is that the log is the authoritative source of truth and state is rebuilt from it — not a side-channel of changes emitted after a database already committed the truth elsewhere.

Kafka can serve as an event store, but with sharp caveats. Log compaction — Kafka’s headline feature — keeps only the latest value per key, which directly destroys the history event sourcing depends on; you must use retention-based topics with log.retention.ms = -1 (infinite), not compacted ones. Per-aggregate optimistic concurrency (“append only if I’m at version N”) has no clean primitive in Kafka, whereas a purpose-built store like EventStoreDB makes expectedVersion a first-class append condition. The common production pattern: keep the raw event topic forever as the source, and publish derived current-state to a separate compacted topic for read models — the compacted topic is a projection, never the truth.

PropertyCRUD tableChange log / CDCEvent sourcing
Source of truthCurrent rowThe DB it tailsThe event log itself
HistoryLost on UPDATEOften time-limitedComplete, forever
State as-of past timeImpossibleHard / partialFold up to timestamp
Rebuild a new viewBackfill scriptsLimited by retentionReplay the whole log

CQRS: projections are disposable read models

You cannot serve a query like “show the dashboard” by folding the entire log on every request — that would be ruinously slow. So event sourcing almost always pairs with CQRS: the write side appends events; the read side runs projections that consume the stream and materialize purpose-built read models (a SQL table, an Elasticsearch index, a denormalized cache). Each projection is a tiny program: for every event, update its own table. Because projections are derived, they are disposable — drop the table, replay the log, get it back. Need a brand-new view six months in? Write a projection and replay history through it; the data was always there.

Two properties make this safe in production. First, projections must be idempotent: the same event may be delivered more than once (retries, at-least-once delivery), so applying it twice must equal applying it once. The standard mechanism is to track the last processed event version per stream and ignore any event whose version is <= the last seen. Second, the read side is eventually consistent: there is real lag between an event being appended and the projection catching up. EventStoreDB and similar stores document this read-model lag explicitly — the read model “converges to the correct state given time,” it is not guaranteed current at any instant.

Why this works

That lag is a UX problem, not just an infra one. A user clicks “Save”, you append the event, then redirect to a list rendered from the projection — which hasn’t caught up, so their change isn’t there. The naive fix (poll until it appears) leaks the architecture to the user. The real fix is to return the new state optimistically from the command result, or read-your-own-writes from the write side for that one screen, and let the projection settle behind the scenes.

The hard parts: versioning, GDPR, and replay cost

This is where event sourcing earns its reputation. Schema versioning is unavoidable and permanent: you can never delete an old event shape, because old events written in that shape live in the log forever and must still be replayable. When OrderPlaced gains a field, every historical OrderPlaced lacks it. The standard answer is upcasting — a chain of transformation functions that lift an old event shape to the current shape at deserialization time, so the rest of your code only ever sees the latest version. Upcasters accrete; they are code you carry indefinitely.

GDPR’s “right to be forgotten” collides head-on with an immutable log. You legally must erase a user’s personal data, but the log is append-only and you cannot rewrite history without breaking every downstream replay and audit guarantee. The dominant technique is crypto-shredding: encrypt each user’s PII with a per-user key stored outside the log; to “forget” them, throw away the key, rendering the ciphertext permanently unrecoverable while the event structure stays intact. Note the sharp caveat seniors must flag: regulators may still treat undeletable encrypted PII as personal data, so crypto-shredding is a pragmatic mitigation, not a guaranteed legal slam-dunk — get counsel involved.

Finally, replay cost is unbounded if you do nothing. Folding millions of events to load one aggregate gets slow; a financial system replaying terabytes of price ticks can take minutes per rebuild. The fix is snapshotting: periodically persist the folded state at version N, then on load read the snapshot and replay only events after N. Snapshots are a pure performance optimization and a footgun — if a snapshot drifts from the events (logic changed, a write got lost mid-stream), it silently serves wrong state and masks the bug because replay never re-derives it. Defensive teams checksum snapshots and rebuild on mismatch.

Pick the best fit

A user exercises GDPR erasure. Their PII is embedded across hundreds of immutable events in the append-only store. Pick the approach a senior actually ships.

Quiz

Loading one aggregate now folds 4 million events and takes too long. What's the senior fix?

Quiz

Why must a projection that builds a read model be idempotent?

Order the steps

Order the lifecycle of a write in an event-sourced + CQRS system:

  1. 1 Command arrives; load the aggregate by folding its events (or snapshot + tail)
  2. 2 Validate the command against the current folded state
  3. 3 Append the resulting event(s) to the stream with an expected-version check
  4. 4 Projections consume the new event asynchronously and update read models
  5. 5 Queries read the (eventually consistent) read model, not the raw log
Recall before you leave
  1. 01
    A colleague says 'we already publish events to Kafka, so we're event-sourced.' Explain why that may be false and what would actually make it event sourcing.
  2. 02
    Walk through why schema versioning in event sourcing is permanent, and how upcasting handles it without rewriting history.
Recap

Event sourcing stores the immutable, append-only stream of state-changing events as the source of truth, and derives current state as a left-fold over that log; the “current state” is a disposable cache you can recompute at will. That single inversion buys a complete audit trail, temporal queries (state as-of any past instant), and deterministic debugging by replay — none bolted on, all falling out of never destroying data. It is distinct from a CDC change log or a plain Kafka topic, where a database elsewhere holds the truth; in true event sourcing the log is authoritative, and Kafka can only serve if you avoid compaction and keep infinite retention. CQRS pairs naturally: projections consume the stream into purpose-built, disposable read models that must be idempotent and are only eventually consistent, so reads lag the write side. The hard parts are permanent: you can never delete an old event shape, so schema changes are handled by upcasting; GDPR erasure against an immutable log is handled by crypto-shredding (with a real legal caveat); and unbounded replay cost is bounded by snapshotting, which itself becomes a footgun if a snapshot ever drifts from the events it claims to summarize.

Continue the climb ↑Event sourcing: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.