Event sourcing stores every change to an entity as an immutable event, such as MoneyAdded or OrderPlaced, in an append-only log that is the source of truth: current state is what you get by replaying the events in order. CQRS (command query responsibility segregation) splits the model that changes data from the models that answer queries. The two fit together: the event log is the write model, and projections built from it are the read models. You gain complete history and freely shaped views; you pay with eventual consistency, events you must support forever, and a lot of new machinery.
Picture it first: the bank passbook
Your bank passbook doesn’t just show a balance. Every line is a fact: “12 Mar, salary credit ₹50,000”, “14 Mar, UPI to grocer ₹1,240”. The balance is a running total you could recompute by adding up the lines. The bank never rubs out a wrong entry; it posts a reversal, so the mistake and its correction both stay visible.
At the branch, one clerk posts entries and checks the rules: is there enough money, is the account frozen? Other staff read her ledger to prepare everything else: the ATM balance, the monthly statement, the interest certificate. They work from copies, so a statement can trail the ledger by a few minutes, and each copy is laid out for its own reader.
Map it back: passbook lines are events, the ledger is the event store, adding up is replay, the posting clerk is the command side, and the statements are CQRS read models that may lag. Martin Fowler makes the same point: an account is itself an example of event sourcing.
From overwriting rows to appending facts
A CRUD wallet service keeps one row per wallet and overwrites the balance on every payment. The row says where you are, not how you got there; a disputed debit needs an audit table someone remembered to write.
An event-sourced wallet keeps a stream per wallet, say wallet-42, and appends one event per change. Each event has a past-tense type, a payload and a version, its position in the stream. The current balance is a fold (a left reduce): start from empty and apply each event in order.
Events should record business intent, not just the new value. The Azure event sourcing guidance puts it well: “two seats were reserved” beats “remaining seats changed to 42”, because the first says what happened and the second only the result.
Two properties follow. The event store is the source of truth: tables, caches and indexes are derived and can be rebuilt. And events are immutable: to fix a mistake you append a compensating event such as PaymentReversed, as an accountant would.
In code, the core is two pure functions. evolve applies one event to the state; decide turns a command plus the current state into new events, or refuses.
from functools import reduce
def evolve(balance: int, event) -> int: # balance in paise
match event.type:
case "WalletOpened": return 0
case "MoneyAdded": return balance + event.data["amount_paise"]
case "PaymentSent": return balance - event.data["amount_paise"]
case "RefundReceived": return balance + event.data["amount_paise"]
case _: raise UnknownEvent(event.type)
def decide(balance: int, cmd) -> list:
if cmd.amount_paise > balance:
raise InsufficientFunds() # a command can be rejected
return [Event("PaymentSent", {"amount_paise": cmd.amount_paise, "payee": cmd.payee})]
def handle(stream_id: str, cmd) -> int:
events, version = store.read_stream(stream_id) # or a snapshot plus the tail
balance = reduce(evolve, events, 0)
new_events = decide(balance, cmd)
return store.append(stream_id, new_events, expected_version=version)
Because both functions are pure, tests read like specifications: given these past events, when this command arrives, expect these new events or this error.
Commands, events and aggregates
Three words do most of the work.
A command is a request to change something, named in the imperative: SendMoney, CancelOrder. It goes to exactly one handler and can be rejected. As Udi Dahan puts it, a client sends a command; it doesn’t publish it.
An event is a fact, named in the past tense: PaymentSent, OrderPlaced. It has already happened, so it can’t be rejected, only answered by a later event. Events are published to any number of subscribers.
An aggregate is the consistency boundary: the data one command must see consistently to check its rules. One aggregate is one stream. Take a Swiggy-style order:
OrderPlaced → PaymentCaptured → RestaurantAccepted → RiderAssigned → OrderPickedUp → OrderDelivered
A CancelOrder command loads that stream, sees OrderPickedUp, and is rejected. A minute earlier it would have produced OrderCancelled and, later, a refund. The rule lives in decide; the reason for every state is in the stream.
Keep aggregates small: an order stream has about ten events and goes quiet, while a wallet stream grows forever (the snapshot problem below). If one aggregate needs another’s data to decide, the boundary is probably wrong; see finding service boundaries with DDD.
Domain events and integration events
The events in your store are domain events: fine-grained and shaped by your model (DeliveryNoteEdited, CouponReapplied). They change when the model changes, so other services shouldn’t depend on them. Publish integration events instead: fewer, coarser, versioned contracts such as OrderPlaced with a total and a restaurant id. It is the same reason the transactional outbox publishes designed events rather than table rows.
In the interview
Say it in one breath: “A command is a request that can fail and goes to one handler; an event is a fact that already happened and goes to any number of subscribers.”
Writing safely: append with an expected version
Two devices pay from the same wallet at once. Both handlers load wallet-42 at version 7 with ₹1,000, both find ₹800 affordable, and both decide to append PaymentSent ₹800. If both appends succeed, the balance ends at −₹600: a lost update.
The fix is optimistic concurrency on the stream. Every append states the version it expects. The first, expecting 7, succeeds as version 8. The second, also expecting 7, is rejected; its handler reloads (now ₹200), runs decide again and rejects the command.
Purpose-built stores expose this directly. KurrentDB (formerly EventStoreDB) takes an expected state on every append: any (no check), no stream, stream exists, or a specific revision; a mismatch throws. It also drops an event whose id was just appended to the same stream, so an immediate retry doesn’t write twice. On PostgreSQL, a unique constraint does the job:
CREATE TABLE events (
global_position BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
stream_id TEXT NOT NULL,
version INT NOT NULL, -- 1, 2, 3 … within the stream
event_id UUID NOT NULL UNIQUE, -- a retried append becomes a no-op
event_type TEXT NOT NULL, -- 'PaymentSent', not a class name
schema_version SMALLINT NOT NULL DEFAULT 1,
data JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}', -- correlation id, user, command id
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (stream_id, version) -- the optimistic concurrency check
);
-- The handler loaded wallet-42 at version 7, so its new event is version 8.
INSERT INTO events (stream_id, version, event_id, event_type, data)
VALUES ('wallet-42', 8, '6f1c0a4e-2b7d-4e7a-9c1e-0d5b3a9f8e21', 'PaymentSent',
'{"amount_paise": 80000, "payee": "merchant-9"}');
-- A second writer that also loaded version 7 gets unique_violation (SQLSTATE 23505):
-- reload the stream, decide again, then append or reject.
A command that produces several events appends them in one transaction with consecutive versions, so they land together or not at all. Loading an aggregate is WHERE stream_id = $1 ORDER BY version, served by the unique index. global_position orders events across streams for subscribers, and it is trickier than it looks (see the deep dive).
Why Kafka alone makes a poor event store
Kafka looks like an event store: an append-only, replayable log. But it lacks three things an event store needs per aggregate:
- A conditional append per stream. A producer can’t say “append only if this key is at version 7”. KIP-27 proposed an expected-offset check in 2015 and is still listed as under discussion. Even that would be per partition, and a partition holds thousands of aggregates, so unrelated wallets would conflict.
- Reading one aggregate. Loading
wallet-42means scanning its whole partition. - Keeping everything. Retention deletes old data unless you configure otherwise, and compaction keeps only the latest record per key: exactly the history you wanted.
Azure’s guidance is blunt: don’t confuse an event store with a message broker such as Kafka, which typically lacks per-entity reads and optimistic concurrency. Teams that use Kafka anyway usually give each partition a single writer and rebuild those guarantees themselves.
The usual design is event store for truth, Kafka for distribution: a subscriber reads the store’s global feed in order, translates domain events into integration events, publishes them to Kafka keyed by aggregate id, and saves a checkpoint. It is the outbox pattern with the events table as the outbox (change data capture on that table works too). As microservices.io notes, saving the event is the state change, so every change is published reliably. Consumers still dedupe by event id.
Snapshots: when replay gets long
Most streams are short: an order has about ten events. But a busy merchant’s wallet takes payments all day, and after a few years its stream holds 50,200 events.
Suppose fetching, decoding and applying one event costs about 10 µs (an assumption; measure your own). Rebuilding that wallet costs 50,200 × 10 µs ≈ 0.5 s before every payment.
A snapshot is the folded state at some version, saved so you don’t fold from zero. Take one every 500 events, and a load becomes: read the latest snapshot (say 2 ms), then replay only the events after it. For our wallet that is the 200 events after v50,000, about 2 ms, so the load takes about 4 ms; even the worst-case tail of 499 events keeps it near 7 ms, instead of 500. The price is one extra write per 500 events (0.2%) plus storage.
Four rules keep snapshots safe:
- A snapshot is a cache, not a fact. You can delete every snapshot and regenerate them from the stream.
- Version the snapshot format. When
evolvechanges, old snapshots may encode the old meaning; tag each with the code version that wrote it and rebuild mismatches. - Write snapshots in the background, so a slow snapshot never slows a payment.
- Prefer shorter streams first. Accountants close the books: a stream per account per financial year, opening with
BalanceCarriedForward, stays short and needs no snapshots.
CQRS: one write model, many read models
Event sourcing makes writes easy and most reads hard. “Show my last 20 orders” can’t be answered by loading one stream.
CQRS, a pattern Martin Fowler first heard described by Greg Young, splits one model into two kinds:
- The write model handles commands: it holds aggregates, checks invariants and appends events. It never answers list or search queries.
- Read models answer queries. Each is a denormalised view shaped for one screen or API, with no business rules; Udi Dahan suggests literally one table per view. A projection builds each one by subscribing to the events in order.
Each read model can live in the store that suits it: a PostgreSQL table for “my orders”, Elasticsearch for search, Redis for live counters. Each projection keeps its own checkpoint, the global position of the last event it applied, so each lags by its own amount.
What you gain: each side scales and is tuned on its own, queries become simple lookups with no joins, and a new screen is a new projection rather than a change to the write model. What you pay: more moving parts, duplicated data, and reads that are eventually consistent with writes.
Two clarifications interviewers like:
- CQRS without event sourcing is common. The write side is ordinary tables; read models are fed by an outbox or CDC, or are simply a read replica or a denormalised reporting table.
- Event sourcing without CQRS is rare. Without projections the only efficient query is “load this aggregate by id”, which is why microservices.io says an event-sourced service must use CQRS for queries.
Fowler also warns that for most systems CQRS adds risky complexity, that most uses he has seen went badly, and that it belongs in specific bounded contexts, not a whole system. If only a few queries are demanding, his lighter alternative is a reporting database.
Projection lag and reading your own writes
A projection always runs behind the store: perhaps tens to hundreds of milliseconds on a healthy day, minutes during a backlog or rebuild. Most screens don’t care. The one that does is where a user lands right after acting: a customer places an order, opens “My orders”, doesn’t see it, and orders again.
Measure lag as head position − checkpoint and as the age of the newest applied event, and alert on both. Then pick a strategy for screens that need read-your-writes:
- Return a position and wait for it. The command’s response carries the new global position; the client sends it with its next query, and the read side holds the request until its checkpoint reaches it, polling every 20–50 ms for up to about a second, then answers anyway, marked stale.
- Read your own aggregate from the write side. The confirmation page loads the order’s stream directly: current by definition and cheap for a short stream.
- Optimistic UI. The app shows the new order at once, marked “confirming”, until the read model catches up.
- Update one critical view inline, in the same transaction as the append, if it lives in the same database. No lag for that view; you pay with slower writes.
Tip
A stale read is fine for display as long as the command side checks again. Show “3 seats left” from a lagging read model, but let the ReserveSeats command re-check against the aggregate. The read model informs; the write model decides.
Checkpoints, idempotency and rebuilds
A projection is a loop: read the batch after the checkpoint, apply it to the view, save the new checkpoint. A crash between the last two steps re-applies events after restart, so delivery is at-least-once. Two ways make that harmless:
- Same database, same transaction. Commit the view changes and the checkpoint together; a crash rolls back both and the batch is re-read. Marten, a .NET event store on PostgreSQL, commits each batch of projected documents with its progression row this way.
- Different store, versioned writes. Elasticsearch can’t join your PostgreSQL transaction, so upsert by aggregate id and send the stream version as the document version; an older event can’t overwrite a newer one. The idempotent consumers article covers these techniques in depth.
Rebuilding is a superpower and an operation to plan: after a projection bug, or when a feature needs a view of all history. Do it blue-green: build orders_view_v2 from position 0 while v1 keeps serving, let v2 catch up with live traffic, switch reads, drop v1.
Size it first. With 500 million events and a projector applying 25,000 events a second in batches, a rebuild takes 500,000,000 ÷ 25,000 = 20,000 s, about 5.6 hours. If the view needs only per-stream order, split by a hash of the stream id across 8 workers: about 42 minutes.
Replays must not touch the outside world
A rebuild re-reads every OrderDelivered event. If the handler that texts “Your order has arrived” is wired like a projection, a rebuild sends millions of stale messages. Fowler’s advice: put external calls behind a gateway that knows when it is replaying and stays quiet.
Events are forever: schema evolution
You can migrate a table, but an event written today will be read by code written years from now. Treat event schemas like a public API, with stricter rules:
- Add, don’t change. New optional fields are safe if readers ignore unknown fields and default missing ones (a tolerant reader).
- Never change the meaning of an old field. If
MoneyAdded.amountwas rupees as a decimal and you want integer paise, introduce version 2 withamount_paise. - Upcast on read. A function turns
MoneyAdded v1 {amount: 20.5}intov2 {amount_paise: 2050, currency: "INR"}as events load. Chain v1 → v2 → v3, so domain code sees only the latest shape while stored bytes stay untouched. - Name types for the domain, not the code. KurrentDB’s docs advise against using a class name as the event type; it couples storage to code.
- Rewriting history is the last resort: it undermines the audit trail you adopted event sourcing for.
Wrong events written by a bug are fixed the accountant’s way, with correcting events, plus an upcaster if replay must treat them specially. Formats and compatibility rules are in serialization and schema evolution.
Deleting personal data from an append-only log
Right-to-erasure rules, such as Europe’s GDPR, clash with a store you never edit. Plan for them on day one:
- Keep personal data out of events. Put a customer id in
OrderPlaced, and keep name, phone and address in an ordinary table you can delete from. - Crypto-shred when personal data must live in events. Encrypt those fields with a per-person key held in a separate key store. To forget someone, delete the key: the ciphertext in events and backups becomes unreadable, and the stream stays intact. You pay with encryption on every write and read, and with managing millions of keys; see encryption and key management.
Either way, projections hold plaintext copies, so erasure must also purge or rebuild those read models.
What you gain and what you pay
What you gain:
- A complete audit trail, with who and why in each event’s metadata, recorded as part of the write.
- Temporal queries: “what was this wallet’s balance at 23:59 on 31 March?” is a fold over events recorded up to then. If late corrections matter, store both when a thing happened and when you recorded it.
- Debugging by replay: copy one stream into a test and step through it.
- New read models from old history: a fraud model sees three years of behaviour on day one.
- Reliable publishing: the state change and the event are one write.
What you pay:
- A learning curve, and Azure notes it is costly to migrate to or away from.
- Eventual consistency on every read model, and schema evolution forever.
- Harder ad-hoc queries: “orders over ₹5,000 in Pune last week” needs a projection or a warehouse.
- Storage that only grows: 10 million wallets × 4 events a day × 365 ≈ 14.6 billion events a year; at about 400 bytes each with indexes, about 5.8 TB a year, kept indefinitely.
- Thinner tooling: fewer admin consoles and BI connectors understand an event store.
Choosing: CRUD, CQRS or event sourcing
| CRUD | CRUD + outbox or CDC | CQRS without event sourcing | Event sourcing + CQRS | |
|---|---|---|---|---|
| Source of truth | Current-state rows | Current-state rows | Write-side rows | The event log |
| History | Only what audit tables record | Published events, while retained | Only what the write side keeps | Every change, kept forever |
| Read side | Same tables or a replica | Caches and indexes fed by events | Views fed by an outbox, CDC or a replica | Projections, rebuildable from position 0 |
| Read freshness | Current | Derived copies lag | Views lag | Views lag; one aggregate can be read current |
| Concurrency control | Row locks or a version column | Same | Same | Expected stream version |
| Schema change | Migrate tables | Plus event contracts | Plus view rebuilds | Old events never change: upcasters |
| Pick it for | Most services | Telling other services; syncing search and caches | Read-heavy screens over complex writes | Ledgers, audit-heavy domains, long lifecycles |
Use event sourcing where history is part of the product: wallets and ledgers (see design a double-entry ledger), order and booking lifecycles, insurance claims, compliance-heavy and collaborative domains. Avoid it for simple CRUD, reference data such as a catalogue, MVPs, and teams new to event-driven design. Decide per part of the system, as Azure suggests: the payment ledger event-sourced, user profiles in plain tables.
In the interview
Answer “would you use event sourcing here?” per bounded context: “For the wallet ledger, yes: we need history, disputes and point-in-time balances. For profiles and the menu, no: plain tables plus an outbox.” That shows you know what it costs.
Going deeper: designing the event store
Global position and the gap problem
Projections read WHERE global_position > checkpoint ORDER BY global_position. On PostgreSQL that position comes from a sequence, which hands out numbers at insert time, not commit time. Two transactions draw 101 and 102; 102 commits first; a projector reads it and saves checkpoint 102; then 101 commits and is never seen. Some holes never fill: the PostgreSQL docs say a value isn’t reused when its transaction aborts, so a rolled-back append burns its number.
Three fixes, simplest first:
- Serialise appends with a single writer or a lock held until commit. Positions commit in order, but every append queues behind one lock.
- Use a high-water mark: process only up to the highest position below which no running transaction could still commit. Marten’s daemon holds at a gap while any transaction that might own it is running.
- Read in commit order from the database’s log, using logical decoding or CDC.
Purpose-built stores give you an ordered all-events feed; on a general-purpose database, ordering it safely is your job.
Aggregate size, idempotency and cross-stream rules
Every command on an aggregate competes for its next version, so a big aggregate is a contention point. Make a restaurant one aggregate holding all its orders, and every order in the dinner rush conflicts with every other. Make each order its own stream.
Clients retry, so give each command an id (an idempotency key) and store it under a unique index, or derive event ids from it. A retried SendMoney becomes a no-op, not a second payment.
“One UPI handle per person” spans streams, which an expected version can’t protect. A neat trick: make the handle its own stream, handle-asha@okbank, and append HandleClaimed expecting “no stream”; the second claimant fails. KurrentDB 25.1 and later can also append to several streams atomically, each with its own expected state.
Sagas and process managers
Workflows across aggregates are driven by events. A process manager subscribes to OrderPlaced and sends CapturePayment; on PaymentFailed it sends CancelOrder. Each step is a local append, and failures are compensated, not rolled back; see two-phase commit vs sagas and workflow engines.
Cheat sheet
| Term | Remember it as |
|---|---|
| Event sourcing | Append immutable facts per stream; state is a fold over them |
| Command vs event | Request that can fail, to one handler; fact, to many subscribers |
| Aggregate | Consistency boundary; one stream; keep it small |
| Expected version | Append only if the stream is at version n; on conflict reload and re-decide |
| PostgreSQL store | UNIQUE (stream_id, version); beware sequence gaps in the global position |
| Kafka | Distribution, not truth: no per-key conditional append |
| Snapshot | Cached fold every N events: about 0.5 s becomes about 7 ms |
| CQRS | One write model; read models per query, fed by projections |
| Projection | Checkpoint and view in one transaction, or versioned upserts |
| Read-your-writes | Wait for a returned position; read the aggregate; optimistic UI |
| Rebuild | Blue-green from position 0; no side effects during replay |
| Schema evolution | Add fields, tolerant reader, upcast on read; never change meaning |
| Erasure | PII out of events, or crypto-shred per-person keys |
How to say it in the interview
“For the wallet I’d event-source the ledger. Each wallet is a stream of facts,
MoneyAdded,PaymentSent,RefundReceived, and the balance is a fold over them.SendMoneyloads the stream, or a snapshot plus the tail, checks the balance, and appendsPaymentSentwith the expected version; if another payment got in first, the append conflicts and we reload and re-check, so nobody overdraws. Projections build a Postgres history table and a search index, and a publisher forwards integration events to Kafka. Views lag by milliseconds, so the payment response carries the new position and the history screen waits for it. Profiles stay plain CRUD.”
Why not keep an audit table next to normal CRUD? Often you should. But an audit table isn’t the truth: it can drift from the state and nothing replays it. Event sourcing guarantees every state came from an event, which enables rebuilds, temporal queries and new views from old history.
Two commands hit the same aggregate at once. What happens?
Both load version n, but only the first append expecting n succeeds. The second gets a conflict, reloads, re-runs decide, and either appends at n + 1 or rejects the command. In PostgreSQL that is a unique violation on (stream_id, version).
Can Kafka be the event store? Not on its own: no “append only if this key is at version n”, no cheap read of one aggregate, and retention or compaction drop history. Keep truth in a store with per-stream concurrency and publish to Kafka from it.
A user doesn’t see their order right after placing it. Fix it. Return the new position from the command and have the query wait, with a timeout, until the projection’s checkpoint reaches it; or read that order from its stream; or show it optimistically in the UI.
How do you change an event’s schema, or delete a user’s data? Add optional fields and keep readers tolerant; for breaking changes add a version and upcast on read, never rewriting history. For erasure, keep personal data out of events, or encrypt it per user and delete the key.
Where to go next
- Change data capture: CDC versus outbox versus event sourcing.
- Transactional outbox: integration events without event sourcing.
- Idempotent consumers and the inbox pattern: projections that survive redelivery.
- Publish/subscribe: fanning events out.
- Optimistic vs pessimistic locking: version checks in general.
- Two-phase commit vs sagas: workflows driven by events.
- Strong vs eventual consistency: read-your-writes and other session guarantees.
- Event-driven architecture: the bigger picture.
References and further reading
- Event Sourcing (Martin Fowler)martinfowler.comThe original write-up: complete rebuilds, temporal queries, replay, snapshots, accounts as event sourcing, and gateways that stay quiet during replay.
- CQRS (Martin Fowler, bliki)martinfowler.comSeparate update and display models, the reporting-database alternative, and the warning that CQRS adds risky complexity and belongs only in some bounded contexts.
- Azure Architecture Center: Event Sourcing patternlearn.microsoft.comRehydration, optimistic concurrency, snapshots every N events, intent-focused events, upcasting and tolerant readers, crypto-shredding, why Kafka is not an event store, and when not to use the pattern.
- Azure Architecture Center: CQRS patternlearn.microsoft.comTask-based commands, separate read and write stores kept in sync by events, outbox plus idempotent consumers, and eventual consistency of read models.
- Pattern: Event sourcing (microservices.io, Chris Richardson)microservices.ioEvent sourcing as a way to publish events reliably, the event store as a broker, snapshots, and why queries need CQRS.
- Clarified CQRS (Udi Dahan, 2009)udidahan.comStaleness and collaboration as the drivers, one query table per view, and commands that are sent versus events that are published.
- KurrentDB documentation: Appending eventsdocs.kurrent.ioExpected stream state (any, no stream, stream exists, a revision) for optimistic concurrency, de-duplication by event id, event type naming and atomic multi-stream appends.
- Marten documentation: Async daemonmartendb.ioProjections on PostgreSQL: the high-water mark, sequence gaps from rolled-back appends, and committing projected documents together with their progression.
- PostgreSQL documentation: Sequence manipulation functionspostgresql.orgValues from nextval are never reused after an abort, so sequences have gaps and cannot be gapless.
- KIP-27: Conditional Publish (Apache Kafka wiki)cwiki.apache.orgA 2015 proposal for an expected-offset check on produce requests, per partition, still marked under discussion.