System Design
Advanced 14 min read Messaging & events

Event sourcing and CQRS

In one line

Event sourcing stores every change as an immutable event and derives current state by replaying them; CQRS separates the write model that validates commands from read models built for queries. They work well together but are costly, so use them where history and auditability are the product.

What it is

In a normal (state-based) design, a row holds the current state and updates overwrite it: balance = 750. In event sourcing, you store the facts that happened, in order: AccountOpened, MoneyAdded 1000, MoneySpent 250. The current state is the result of replaying the events. Events are never updated or deleted; mistakes are fixed with new compensating events (MoneyRefunded), just like an accountant's ledger.

Events are grouped into streams, usually one per aggregate (one wallet, one order, one cart). To handle a command, you load the aggregate's events (or a recent snapshot plus newer events), rebuild its state, check business rules, and append new events. Concurrency is controlled per stream with an expected version: 'append version 18 only if the latest is 17'.

CQRS (Command Query Responsibility Segregation) means using different models for writes and reads. Commands go to a write model that enforces rules. Queries go to read models (projections) shaped for each screen: a balance table in Postgres, a search index in Elasticsearch, a leaderboard in Redis. Projections are built by consumers that read the event stream, so they are eventually consistent, typically milliseconds to seconds behind.

The two are separate ideas. You can use CQRS without event sourcing (a normal database plus CDC into read models), and event sourcing almost always needs some CQRS, because replaying events for every query is too slow. A Kafka topic is a good transport for events but a weak primary event store: it does not give per-aggregate optimistic concurrency or efficient loading of one aggregate's events.

Why interviewers ask

Fintech, trading, ledgers, order pipelines and audit-heavy systems often use event sourcing, and interviewers at payments and trading companies ask about it. More generally, they test judgement: can you explain the benefits (full audit trail, replay, temporal queries, many read models) and the real costs (eventual consistency, schema evolution of events forever, snapshots, deleting personal data, more moving parts)? Proposing event sourcing for a simple CRUD app is a red flag; proposing it for a ledger with clear reasoning is a strong signal.

How it works

  1. 1Design events as past-tense business facts with the data consumers need: OrderPlaced {order_id, items, total}, PaymentCaptured {payment_id, amount}. Avoid 'RowUpdated'-style events.
  2. 2Store them in an event store: EventStoreDB, a Postgres table with a unique (stream_id, version), or DynamoDB with a conditional write on version. Appends are atomic per stream.
  3. 3Handle a command: load the stream (or latest snapshot + later events), fold events into state, validate, and append new events with the expected version. On a version conflict, reload and retry.
  4. 4Snapshot long streams: every N events (for example 100), store the folded state, so loading reads one snapshot plus at most 99 events.
  5. 5Publish events to consumers: subscribe to the store's feed, or use an outbox/CDC into Kafka keyed by stream id.
  6. 6Build projections: each consumer applies events to its own read model, idempotently, tracking the last position it applied. A new screen means a new projection, built by replaying history from the start.
  7. 7Handle read-after-write: the read model lags. Return the new state from the command, read the write model for the user's own latest change, or wait until the projection reaches the returned version.
  8. 8Evolve schemas: events are stored forever, so add a schema version and upcast old events to the new shape when reading; never rewrite history in place.
  9. 9Handle personal data: keep personal data out of events where possible, or encrypt it per user and delete the key to 'forget' the user (crypto-shredding).

Worked example · A wallet with an audit trail

  1. Wallet 42's stream: v1 WalletOpened, v2 MoneyAdded 1000, v3 MoneySpent 250 ... up to v17. Replaying gives balance Rs 750.
  2. Command: spend Rs 250 for order ord_981. The service loads the latest snapshot (v15, balance 800) and events v16-v17 (MoneySpent 100, MoneyAdded 50), so balance = 750, enough. It appends v18 MoneySpent 250 with expected version 17.
  3. At the same moment another request also tries to append v18 for a Rs 600 spend. Its insert fails on the unique (stream_id, version). It reloads: balance is now 500, not enough for 600, so it rejects. No overdraft, no lock held across requests.
  4. Projections: a balances table (Postgres) updates to 500 within about 50 ms; a statement projection writes a line item; a fraud projection updates spending velocity in Redis.
  5. Six months later, compliance asks: what was the balance on 3 March at 14:05, and why? Replay events up to that timestamp and show each one. With state-based storage, that history would need a separate audit log that might not match.
  6. Scale: 10M wallets, average 40 events per year at about 300 bytes = 120 GB per year, a manageable size for a partitioned Postgres table or a dedicated event store.

Picture it

A bank passbook. It does not just show 'balance: 750'; it lists every deposit and withdrawal, and the balance is the running total. You never erase a line; a mistake is corrected with a new reversing line. Different people can read the same passbook to make their own summaries: monthly spending, tax interest, fraud checks.

Reference code

-- Event store: one append-only table; (stream_id, version) is unique.
CREATE TABLE events (
  stream_id   text        NOT NULL,          -- e.g. 'wallet-42'
  version     int         NOT NULL,          -- 1, 2, 3 ... per stream
  event_type  text        NOT NULL,          -- 'MoneyAdded', 'MoneySpent'
  payload     jsonb       NOT NULL,
  schema_ver  int         NOT NULL DEFAULT 1,
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (stream_id, version)
);

-- Append with optimistic concurrency: the caller loaded version 17,
-- decided the command is valid, and appends version 18.
-- If another writer already appended 18, the primary key rejects this
-- insert, and the caller reloads and retries the command.
INSERT INTO events (stream_id, version, event_type, payload)
VALUES ('wallet-42', 18, 'MoneySpent', '{"amount": 250, "order_id": "ord_981"}');

Key patterns

Optimistic concurrency per stream

When: Every append

How: Append with expected version; a unique key or conditional write rejects concurrent appends; reload and retry.

Snapshots

When: Streams with hundreds or thousands of events

How: Save folded state every N events with its version; load snapshot plus newer events.

Projections with checkpoints

When: Every read model

How: Consumer stores the last applied position in the same transaction as the read-model update; rebuild by resetting to zero.

Upcasting

When: Event schemas change

How: Keep a schema version per event; convert old versions to the current shape on read; never edit stored events.

Crypto-shredding

When: Personal data in immutable events must be deletable

How: Encrypt personal fields with a per-user key stored elsewhere; delete the key to make the data unreadable.

Real-world use

Accounting ledgers and banking

Double-entry bookkeeping is event sourcing in its oldest form: immutable journal entries, balances derived from them, corrections by reversing entries. Core banking and wallet ledgers follow the same idea.

LMAX exchange

Martin Fowler's well-known write-up of the LMAX architecture describes an in-memory business logic processor whose state is rebuilt from an input event log, achieving millions of orders per second on a single thread.

EventStoreDB and Axon

Purpose-built event stores and frameworks offer streams with expected-version appends, subscriptions for projections and tooling for snapshots and replays.

Git

Git stores commits as an immutable history; the working tree is derived from it. Branching and 'what did this file look like last week' are temporal queries over events.

Order and trading platforms

Order lifecycles (placed, partially filled, filled, cancelled) and exchange audit requirements make event logs natural; read models feed order books, positions and P&L screens separately.

In a project

A loan platform keeps loan state in one row that many services update. Disputes are hard to resolve because nobody can tell how a loan reached its current state, and a regulator asks for full history of every status and amount change.

  1. Event-source only the loan aggregate, not the whole platform: LoanApplied, LoanApproved, Disbursed, EmiPaid, EmiMissed, PenaltyApplied, LoanClosed.
  2. Store events in Postgres with a unique (loan_id, version) and append with expected version; snapshot every 50 events.
  3. Publish events to Kafka through an outbox, keyed by loan_id.
  4. Build read models: loan summary for the app, collections queue for agents, and a nightly warehouse feed for risk.
  5. Keep borrower personal data in a separate store; events reference borrower_id only.
  6. Write upcasters for event schema changes and a projection rebuild job that replays from version 1.

Impact: Disputes are answered by showing the exact event history, the regulator's request becomes a query, new dashboards are added by replaying events into new projections, and other low-risk services stay simple CRUD.

Trade-offs

Use it when

  • Audit and history are core requirements: ledgers, wallets, loans, trading, healthcare records
  • Many different read models are needed from the same data
  • You need to answer 'what was the state at time T and why'
  • Domain processes are naturally event-shaped (order lifecycles, workflows)

Avoid it when

  • Simple CRUD with no audit need; the cost far exceeds the benefit
  • Users need strong read-after-write everywhere and the team cannot handle eventual consistency
  • Data must be fully deleted on request and you cannot design for crypto-shredding
  • Using a Kafka topic as the only event store for aggregates that need concurrency control
  • Applying it to the whole system; use it only in the bounded contexts that benefit

Common mistakes

  • Event-sourcing an entire product, including simple settings and profiles.
  • Designing CRUD-style events (OrderUpdated with all fields) that hide business meaning.
  • Editing or deleting stored events to fix bugs instead of appending compensating events.
  • Forgetting optimistic concurrency, so two commands both pass validation on stale state.
  • Ignoring projection lag, so users do not see their own changes.
  • No plan for event schema versioning or for deleting personal data.
  • Confusing CQRS with event sourcing, or claiming CQRS requires two databases.

Interview questions

Quick revision

  • Event sourcing: state = fold(events). Append-only; fix with compensating events.
  • One stream per aggregate; append with expected version.
  • Snapshots every N events to keep loads fast.
  • CQRS: write model validates; read models (projections) serve queries.
  • Projections are eventually consistent; plan read-your-writes.
  • New read model = replay history into a new projection.
  • Events live forever: version them, upcast on read.
  • Personal data: keep out or crypto-shred.
  • Use only where history matters; not for CRUD.

Check yourself

1. In an event-sourced system, how is a wrong charge corrected?

2. Two requests load wallet stream version 17 and both try to append version 18. What happens with optimistic concurrency?

3. What is the main cost of CQRS read models?

4. Why use snapshots?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free