system-design

Event Sourcing

Store what happened, not what is. You get a perfect audit trail and time travel, and you give up the ability to run a simple query.

Prerequisites: CQRS, Event-Driven Architecture Time to read: ~24 minutes


The problem

A customer says their order total is wrong. You look:

SELECT * FROM orders WHERE id = 4821;
-- id: 4821, total_cents: 8500, status: 'shipped', updated_at: '2026-07-22'

That’s all you have. You know the current state and nothing about how it got there. Was there a discount? A returned item? A price correction? A bug? Who changed it, and when, and why?

🚨 Traditional storage destroys history on every update. UPDATE orders SET total = 8500 overwrites the previous value permanently. The audit trail — if there is one — is a bolted-on log that’s incomplete and can drift from reality.

Event sourcing inverts it: store the events; derive the state.

OrderPlaced        {items: [...], total: 10000}
DiscountApplied    {code: "SAVE15", amount: 1500}
ItemReturned       {sku: "SHOE-42", refund: 0}
OrderShipped       {tracking: "..."}
────────────────────────────────────────────────
Current state (derived): total = 8500, status = shipped

The full history is the data. Nothing is ever overwritten.


The core idea

The event store is an append-only log. Events are immutable facts. Current state is a fold over the events:

def current_state(events):
    state = Order()
    for event in events:
        state = apply(state, event)      # pure function
    return state

Two operations:

🚨 Concurrency control comes from the expected version:

event_store.append("order-4821", OrderShipped(...), expected_version=3)
# Fails if the stream is already at version 4 — someone else appended concurrently

This is optimistic concurrency applied to the stream, and it’s how invariants are protected without locking.

Snapshots solve the replay cost: periodically store the derived state at version N, and replay only events after it. An order with 50 events replays instantly; an account with 500,000 needs snapshots.


What you gain

1. A complete, authoritative audit trail. 🚨 Not a log that might be incomplete — the audit trail is the data. There is no way for state to change without an event, because the event is the only mechanism. This is why event sourcing appears so often in finance, healthcare, and anywhere regulated.

2. Time travel. Reconstruct the state at any past moment:

state_at_month_end = fold(events_before("2026-06-30"))

“What did this look like when the customer complained?” becomes a query rather than an archaeology project.

3. New projections from old data. 🚨 This is the most underrated benefit. The product team asks for a metric nobody thought to track. In a traditional system, you start collecting it now and have no history. With event sourcing, you build a new projection and replay three years of events — the answer exists retroactively.

4. Debugging by replay. Reproduce a bug exactly by replaying the events that caused it. No guessing about intermediate states.

5. Intent is preserved. ItemRemovedFromCart and CartCleared both reduce the item count to zero, but they mean completely different things. A state-based model records only “count = 0” and loses the distinction — which is exactly the information a product analyst wants.

6. Natural fit for event-driven integration. The events you store are the events you publish. No dual write, no outbox needed.


What it costs — and this list is why most systems shouldn’t do it

⚖️ Event sourcing is a significant commitment. Be honest about it.

🚨 1. You cannot query it. “Find all orders over $100 shipped to Lahore last week” is impossible against an event log. Every query requires a projection, which means CQRS is effectively mandatory — and every new query need is a new projection to build, backfill, and maintain.

🚨 2. Schema evolution is permanent and hard. Events from three years ago must still be readable by today’s code, forever. You cannot migrate them (they’re immutable facts) — you must handle every historical version. Strategies: weak schemas (tolerate missing fields), upcasting (transform old events to the new shape on read), or versioned event types. All of them accumulate.

🚨 3. GDPR and the right to erasure. “Delete all of this user’s data” directly contradicts an immutable append-only log. Solutions exist — crypto-shredding (encrypt personal data per user and delete the key, rendering the events unreadable) is the standard one — but this needs designing in from the start, not retrofitting.

4. Eventual consistency everywhere, since reads come from projections. → CQRS

5. Replay cost grows. Long-lived aggregates need snapshots; rebuilding all projections after a change can take hours or days.

6. Unfamiliarity. Most engineers have never used it. Onboarding is slow, the tooling is thinner, and mistakes are expensive because the store is append-only.

7. Storage grows without bound. Every change is retained forever.

8. 🚨 Modelling mistakes are permanent. A badly-designed event is in the log forever, and every future version of your code must handle it. In a state-based system, a bad column is a migration.


Doing it well

Model events as business facts, not CRUD.

❌ OrderUpdated {field: "status", old: "pending", new: "shipped"}
✅ OrderShipped {tracking_number: "...", carrier: "DHL", shipped_at: "..."}

The first is a database changelog wearing a costume — it preserves no intent and consumers can’t do anything meaningful with it. If your events are named XCreated, XUpdated, XDeleted, you have built an audit log, not an event-sourced system, and you’ve taken on all the cost for none of the benefit.

Events are immutable. No updates, no deletes. To correct a mistake, append a compensating event (PaymentRefunded, OrderCorrected) — which is also more honest, because the mistake really did happen.

Keep aggregates small. The aggregate is the consistency boundary and the replay unit. A “Customer” aggregate accumulating every event for ten years becomes unreplayable. Smaller aggregates with shorter lifecycles are far more manageable. → Service Decomposition

Design for schema evolution from day one. Version every event. Add fields only. Write upcasters when you must change shape. Assume today’s decisions are permanent.

Snapshot long streams. Every N events, store the derived state. Replay from there.

Separate internal events from published ones. 🚨 Your internal event schema is an implementation detail; publishing it makes it a public contract you can never change. Translate at the boundary.


Event sourcing vs an audit log

A distinction worth being precise about:

  Audit log Event sourcing
Source of truth The state table The event log
Can state change without a log entry? ✅ Yes — a bug, a manual fix, a migration Impossible by construction
Completeness Best effort Guaranteed
Can you rebuild state from it? Usually not ✅ By definition
Cost Low High

🎙️ If the requirement is “we need an audit trail,” an audit log is usually the right answer — far cheaper, and it satisfies the requirement. Event sourcing is warranted when you need the other benefits: retroactive projections, time travel as a first-class operation, or a domain where the history genuinely is the truth.

Saying this distinction out loud is a strong signal, because “we need auditability” is the most common (and usually insufficient) justification given for event sourcing.


When it’s genuinely right

Good fits:

Domain Why
Financial ledgers The ledger is an event log. Balances are derived. This is how accounting has worked for 500 years.
Trading and order books Sequence and intent matter; regulatory replay is required
Inventory and warehouse “Why is stock wrong?” needs the full movement history
Insurance policies Long-lived entities with a legally significant change history
Collaborative editing Operations are the natural model → CRDTs
Regulated domains Where you must prove what happened and when

Poor fits:

🚨 The best practical advice: apply it to one bounded context, not the whole system. Event-source the ledger; use ordinary CRUD for user preferences. This is the pattern in successful real-world adoptions, and proposing it selectively rather than universally is the mature position.

🎙️ “I’d event-source the payments ledger, where history is the actual requirement and the domain is naturally event-shaped. User profiles and product catalogue stay CRUD — event sourcing them would be cost with no benefit.”


⚖️ Trade-offs

  Gain Cost
Event sourcing Complete history, time travel, retroactive projections, preserved intent No queries without projections; permanent schema; GDPR conflict
Audit log instead Cheap; satisfies most audit requirements Incomplete; can’t rebuild state
Snapshots Bounded replay time Storage; snapshots must be versioned too
Small aggregates Fast replay; clear boundaries More aggregates to coordinate
One bounded context only Benefits where they matter, cost contained Two paradigms in one system

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Build a bank account. About 100 lines, and it makes everything concrete:

events = []
def apply(state, e):
    if e.type == "Deposited":  return state + e.amount
    if e.type == "Withdrawn":  return state - e.amount
    return state

def balance():
    return reduce(apply, events, 0)

Then add: an expected-version check for concurrency, a snapshot every 100 events, and a projection for “monthly statement.” Then build a second projection — “largest transaction per month” — and replay history to populate it. That retroactive capability is the benefit that’s hard to appreciate until you’ve done it.

2. Feel the query problem. With 10,000 accounts, answer “which accounts have a balance over $1,000?” You can’t — you must replay every stream or build a projection. That constraint is the main cost, experienced directly.

3. Break schema evolution. Store Deposited {amount: 100}. Later change it to Deposited {amount: 100, currency: "PKR"}. Now replay the old events — your code must handle the missing field forever. Write an upcaster.

4. Try GDPR erasure. Store a user’s name in events, then implement “delete this user.” Discover you can’t. Then implement crypto-shredding and watch it work.


Check yourself

1. What's the difference between event sourcing and an audit log? In an audit log, the **state table is the source of truth** and the log is a record kept alongside it — which means state can change without a corresponding log entry (a bug, a manual database fix, a migration script), so the log is best-effort and can drift from reality. In event sourcing, the **event log is the source of truth** and state is derived from it, so it is structurally impossible for state to change without an event — completeness is guaranteed by construction. You can also rebuild any state, at any past point in time, from the log. The trade-off is cost: if the requirement is simply auditability, an audit log satisfies it far more cheaply.
2. Why does event sourcing force you into CQRS? Because you cannot query an event log. "Find all orders over $100 shipped to Lahore last week" requires knowing current state across many aggregates, and the log only contains sequences of changes per stream — answering it would mean replaying every stream. So every read pattern must be served by a **projection**: a derived, queryable read model built by folding events. That's CQRS by definition. The practical consequence is that every new query requirement is a new projection to design, build, backfill from history, monitor, and keep rebuildable — which is a real ongoing cost that teams underestimate.
3. Why is schema evolution harder in an event-sourced system? Because events are immutable historical facts that you can never migrate. In a state-based system, a bad column is fixed with an `ALTER TABLE` and a backfill. In an event-sourced system, an event written three years ago exists in that shape permanently, and today's code must still be able to read it — forever. Strategies: **weak schemas** (tolerate missing or unknown fields), **upcasting** (transform old event versions into the current shape when reading), and **versioned event types** (`OrderPlacedV1`, `OrderPlacedV2`). All of them accumulate complexity over time, which is why modelling events well from the start matters far more than in a mutable system.
4. How do you satisfy GDPR's right to erasure with an immutable log? **Crypto-shredding** is the standard approach: encrypt each user's personal data with a key unique to that user, stored in a separate, mutable key store. To "erase" the user, destroy their key — the events remain in the append-only log but the personal fields are permanently unreadable, which regulators generally accept as erasure. The critical point is that **this must be designed in from the start**: you need to know which fields are personal, encrypt them at write time, and manage keys per data subject. Retrofitting it means re-encrypting or rewriting history, which contradicts the premise. Alternatives — keeping PII outside the event store entirely and referencing it by ID — also work and are sometimes simpler.
5. When is event sourcing genuinely the right choice? When the history *is* the requirement, not a nice-to-have. Financial ledgers (where double-entry bookkeeping is already event sourcing), trading systems needing regulatory replay, insurance policies with legally significant change histories, inventory where "why is stock wrong?" demands the full movement trail, and collaborative editing where operations are the natural model. Also when you genuinely need retroactive projections — the ability to answer a question nobody thought to ask three years ago. It's the wrong choice for CRUD, for systems where only current state matters, and for inexperienced teams. And the mature version of the answer is to apply it to **one bounded context**, not the whole system.

Further reading