CQRS and event sourcing
Separate read models, projections, replay, snapshots, and the operational weight of keeping events forever.
Two ideas that arrive together and should be evaluated apart. CQRS says the model that handles writes and the model that serves reads can be different objects, and often should be. Event sourcing says the write model's state can be the log of what happened rather than a row that overwrites itself. The first is cheap and often right; the second is expensive and occasionally right; and a surprising amount of the industry's regret comes from buying both when it wanted the first.
CQRS: a write model and a read model
The order aggregate that enforces the rules — can this be cancelled, is the total right, did the stock reservation succeed — is the wrong shape for the order-history page, which wants the customer's name, a status label, three product thumbnails and the delivery date, from four tables, sorted, paged. Serving that page from the write model is either a fetch-join tangle or the N+1 problem the JPA course showed; serving it from a read model built for the page is one indexed query:
CREATE TABLE order_summary ( -- the read model: one row per order, exactly the page's columns
order_id BIGINT PRIMARY KEY,
customer_name TEXT, status_label TEXT, thumbnails JSONB, delivered_on DATE, total_paise BIGINT
);Writes go through the aggregate and its repository; reads go through a query service that knows nothing about invariants and everything about the page. The two can be tables in the same database (the honest starting point), a table and a materialised view, or a relational store and Elasticsearch — the pattern says nothing about how many stores, only that the shapes differ.
What it costs is the thing that keeps the two in step: a projection that updates order_summary when an order changes. In one database it can be the same transaction. Across stores it is an event — the outbox from the last lesson — and then the read model is eventually consistent: a customer who cancels and refreshes may see the old status for a moment. That moment is a product decision, and "read your own writes" (route this user's next read to the write model, or return the new state from the command) is the usual answer for the cases that matter.
Projections
A projection is a consumer that folds events into a read model: OrderPlaced inserts the summary row, OrderShipped updates the label, OrderCancelled sets it and clears the delivery date. Three properties make one operational:
- Idempotent, because the outbox relay is at-least-once. An upsert keyed on
order_idhandles a repeatedOrderPlacedby doing nothing new. - Rebuildable: a projection is a derived table, and being able to drop it and replay every event into a fresh one is what lets you add a column, fix a bug in the folding logic, or build a second read model for a new page. That is the strongest argument for keeping events at all.
- Tracked: it records how far through the stream it has got, so a restart resumes rather than replays, and a dashboard can show its lag.
Several projections from one stream is the normal shape: the summary table, the search index, the analytics warehouse, each a consumer, each rebuildable on its own.
Event sourcing: the log is the state
Under CQRS the write model still stores current state — an orders row, updated in place. Event sourcing replaces that row with the sequence of events that produced it: OrderPlaced, LineAdded, PaymentTaken, OrderShipped, appended to a per-aggregate stream and never modified. Loading the order means replaying its events through the aggregate; saving means appending the new ones with an optimistic concurrency check on the expected version, so two commands on the same order cannot both succeed:
List<Event> history = store.load("order-42"); // events 1..n
Order order = Order.replay(history); // fold into state
List<Event> produced = order.cancel(reason); // the decision: returns events, mutates nothing
store.append("order-42", expectedVersion = history.size(), produced); // fails if someone appended firstWhat you gain is real. A complete audit that is the system of record rather than a log beside it. Temporal queries — the order as it was on the 3rd — by replaying to a point. Rebuildable read models for free, since the events were never lost. And a domain model whose decisions are explicit: an aggregate that returns events is a pure function of history and command, which tests beautifully.
What you pay is also real, and it is paid every day the system runs. Events are forever: an OrderPlaced from 2024 with a field you have since renamed must still replay, so every event type is versioned and every old version has an upcaster, for as long as the stream exists. Queries are impossible against the store — "orders over 5000 last month" is a projection, always, which means CQRS is mandatory rather than optional. Deleting is a design problem: a GDPR erasure request against an immutable log is answered with crypto-shredding (encrypt each person's data with a per-person key and destroy the key), which is a system you now own. And the store itself — EventStoreDB, Axon Server, or a hand-built events table with (stream_id, version) unique — is infrastructure with its own failure modes.
Snapshots
An order with forty events replays in microseconds; an account with two million ledger entries does not. A snapshot is the aggregate's state at version n, stored beside the stream, so a load reads the snapshot and replays only events after n. Take one every few hundred events, or on a schedule; treat it as a cache, not a source — it can always be rebuilt from the events, and a change to the aggregate's shape invalidates every snapshot, which is fine precisely because they are disposable.
When not to
The honest test is the audit and temporal-query requirement. If the domain is a history — a ledger, an insurance claim, a trading position, a medical record — event sourcing matches the problem and the costs above are costs you were going to pay somehow. If the domain is current state that happens to change — a product catalogue, a user profile, most CRUD — then a row, an updated_at, and an audit table written by the same transaction give you the audit, and CQRS with an outbox gives you the read models, for a fraction of the operational weight.
The two decisions, separately: CQRS when a read shape and the write shape have diverged enough to hurt, which is common. Event sourcing when the history is the product, which is rare, and then all the way — a half-sourced system, with some aggregates as rows and some as streams, has both sets of costs.