CQRS and Event Sourcing: Two Patterns, Demystified
They get name-dropped together so often people think they are one thing. They are not. Here is what each pattern actually does, the concrete order/ledger example that makes it click, and the honest list of when the complexity tax is not worth it.
Backend engineers who keep hearing 'CQRS' and 'event sourcing' said in the same breath and want to know, concretely, what each one is, when it pays off, and when it quietly wrecks a team. You should be comfortable with a normal CRUD service and a relational database. No prior exposure to either pattern required.
Start with the thing almost every tutorial gets wrong: CQRS and event sourcing are two separate patterns. You can use either one without the other. CQRS is about *splitting the model you write through from the models you read through*. Event sourcing is about *storing the history of what happened instead of the current state*. They pair beautifully, which is exactly why people fuse them in their heads, but they solve different problems and carry different costs.
We will keep one example throughout: an order/ledger for a small commerce system. An account has a balance, orders get placed, paid, and refunded. By the end you will have seen the same domain modelled both as plain CRUD and as an event-sourced aggregate with read-side projections, and you will know which one you actually want.
The mental model: a bank ledger
The cleanest way to feel event sourcing is to think about how a bank tracks your money. The bank does not store a single field called balance and overwrite it on every transaction. It appends rows to a ledger, a deposit here, a withdrawal there, and your balance is *derived* by adding them up. The history is the truth; the balance is a computed view of it.
A bank never overwrites your balance; it appends transactionsEvent sourcing appends immutable events; it never UPDATEs current state
Your balance is the running total of every transactionAggregate state is rebuilt by folding all events in order
A printed statement is a snapshot you can produce on demandA projection / read model is built by replaying events
You can audit any past balance by stopping the sum at a dateTemporal queries: replay events up to time T to see state then
A reconciliation re-adds every line to prove the totalReplaying the full stream rebuilds a read model from scratch
Event sourcing is the ledger discipline applied to your domain: append facts, derive state.
CRUD, by contrast, is the opposite discipline: it stores the balance and throws away how you got there. That is simpler, and for most rows in most apps it is the right call. Event sourcing trades that simplicity for a perfect audit trail and the ability to answer questions you did not know you had, at a real cost we will be honest about.
Either one, alone
CQRS says: stop forcing one model to serve both writes and reads. Event sourcing says: stop storing the current state and store the events that produced it. Each is useful on its own; together they reinforce each other, but coupling them in your mind is the first mistake.
CQRS without event sourcing: a normal table you write to, plus separate denormalised read tables (or a search index, or a cache) kept in sync. Very common, very sane.
Event sourcing without CQRS: store events as the source of truth, but rebuild the current aggregate on every read. Works for low read volume; you just lose the dedicated read-side optimisation.
Both together: writes append events; events feed projections; reads hit purpose-built read models. This is the combination most articles describe, but it is a choice, not a requirement.
The picture: command to query
Here is the full shape when you run both patterns together. Follow the solid line as the write path and the dashed line as the asynchronous flow into the read side.
A command mutates the write model, which appends events to the store; projections consume the stream and build read models the query side serves.
1
A command arrives
The client sends an intent like PlaceOrder or CreditAccount. Commands are imperative and can be rejected, they are requests, not facts.
2
The aggregate decides
The command handler loads the aggregate (by folding its existing events), runs business rules, and decides which events the command produces, or throws if the rules forbid it.
3
Events are appended
The resulting events (OrderPlaced, AccountCredited) are appended to the event store atomically, with an expected-version check to catch concurrent writes.
4
Projections consume the stream
Projectors read the new events asynchronously and upsert denormalised read models, an order-summary table, a balance view, a search index.
5
Queries hit the read side
Reads never touch the event store or the aggregate; they hit the purpose-built read model, which is fast and shaped exactly for the screen that needs it.
State-oriented vs event-sourced
Before any code, sit the two approaches side by side. This table is the honest trade-off in one view, note that the last row is not a footnote.
Dimension
State-oriented (CRUD)
Event-sourced
Source of truth
Current row, mutated in place
Ordered, immutable log of events
History
Lost on every UPDATE
Complete and permanent by construction
Audit trail
Bolt-on (triggers, audit tables)
Free, the log is the audit
Temporal queries
Hard; needs history tables
Replay events up to time T
Read shape
One schema bends to all queries
Many projections, each query-shaped
Bug recovery
Data already corrupted in place
Fix projector, replay to rebuild views
Onboarding cost
Any dev gets it day one
Steep; new mental model + tooling
Operational complexity
Low
High, ordering, versioning, eventual consistency
CRUD optimises for the common case; event sourcing buys capabilities you pay for in complexity.
An event-sourced aggregate in code
Here is the Account aggregate from our ledger. Two things matter: apply rebuilds state by folding events (no current-state column anywhere), and the command methods *decide* new events rather than mutating fields directly.
account.ts
typescript
type AccountEvent =
| { type: 'AccountOpened'; id: string; owner: string }
| { type: 'AccountCredited'; amount: number }
| { type: 'AccountDebited'; amount: number };
class Account {
id = '';
owner = '';
balance = 0;
version = 0;
// Rebuild current state by folding the whole stream.staticrehydrate(events: AccountEvent[]): Account {
const acc = newAccount();
for (const e of events) acc.apply(e);
return acc;
}
privateapply(e: AccountEvent): void {
switch (e.type) {
case'AccountOpened':
this.id = e.id;
this.owner = e.owner;
break;
case'AccountCredited':
this.balance += e.amount;
break;
case'AccountDebited':
this.balance -= e.amount;
break;
}
this.version += 1;
}
// A command DECIDES events; it does not mutate fields directly.debit(amount: number): AccountEvent[] {
if (amount <= 0) thrownewError('amount must be positive');
if (amount > this.balance) thrownewError('insufficient funds');
const event: AccountEvent = { type: 'AccountDebited', amount };
this.apply(event); // keep in-memory state consistentreturn [event]; // caller appends this to the store
}
}
The command handler ties it to the store. Note the expected version on append, that is your optimistic-concurrency guard: if another writer slipped an event in since you loaded, the append fails and you retry.
debit-handler.ts
typescript
asyncfunctionhandleDebit(store: EventStore, id: string, amount: number) {
const history = await store.load(id); // all events for this accountconst account = Account.rehydrate(history); // fold to current stateconst newEvents = account.debit(amount); // business rules run hereawait store.append(id, newEvents, {
expectedVersion: history.length, // optimistic concurrency
});
}
Now the read side. A projector subscribes to the stream and maintains a denormalised balances table the query side reads from. It never runs business logic, it just translates events into rows.
-- The query side never touches the event store.
-- It reads the projection, shaped exactly for the screen.
SELECT owner, balance
FROM balances
WHERE id = $1;
Eventual consistency between the sides
The read side lags, design for it
Because projections consume the stream asynchronously, there is a window where the write succeeded but the read model has not caught up. Debit an account, immediately re-query the balance view, and you may see the old number. This is not a bug to fix, it is the defining trade-off of CQRS. Handle it: return the new state from the command response, or have the UI optimistically reflect what it just did, or poll until the projection catches up.
The lag is usually milliseconds, but it is never zero, and it can spike when a projector falls behind or restarts. Two rules keep you sane: never read-after-write from the read model to confirm a command (read the command's own result instead), and make projections idempotent so re-delivering an event does not double-count. The upsert/increment pattern above is chosen precisely so a replay rebuilds the same numbers.
Versioning and snapshots
Two operational realities bite once you live with event sourcing for a while: your events are forever, so their shape will need to evolve, and folding thousands of events per read gets slow.
Event schema versioning
Events are immutable history, you cannot 'migrate' a row you have already written. So plan for change: add fields as optional, never repurpose an old field's meaning, and when a real shape change is needed, write an UPCASTER that translates old event versions to the new shape as they are read. Tag events with a version number from day one.
Additive changes are safe: new optional fields, new event types. Old events simply lack the new field; default it.
Breaking changes need an upcaster: a function that reads OrderPlacedV1 and returns OrderPlacedV2 on load, so the rest of the code only ever sees the latest shape.
Never mutate stored events: rewriting history breaks the audit guarantee that justified event sourcing in the first place.
Snapshots are an optimisation, not truth: periodically persist the folded aggregate state (e.g. every 100 events) so rehydration loads the latest snapshot plus the tail, instead of the entire stream. The events remain the source of truth, a snapshot is a cache you can always discard and rebuild.
When NOT to use this
This is the section the tutorials skip. Both patterns are a tax, and most CRUD apps should never pay it. Reach for plain CRUD, or CQRS-without-event-sourcing at most, when:
The domain is simple CRUD. A settings page, a profile, a catalog of products nobody audits, there is no history anyone will ever ask for. Event sourcing here is pure overhead.
You do not need an audit trail or temporal queries. If 'what was the state last Tuesday' is never a question, the biggest payoff evaporates.
Reads and writes have similar shapes and load. CQRS earns its keep when the two diverge hard (write-heavy commands, very different read screens). If they are symmetric, the split is busywork.
The team has never run it. Eventual consistency, replay, versioning, and projection lag are genuinely new skills. Adopting both patterns and a new domain at once is how projects stall.
You are early and the model is still moving. Events are forever; a domain you redesign weekly will leave you with a graveyard of dead event types and upcasters.
The pragmatic middle
You rarely flip a whole system. The mature move is to event-source the one or two aggregates that genuinely need an audit trail and temporal reasoning, money, orders, inventory, and leave everything else as boring, fast CRUD. Patterns are per-aggregate decisions, not architecture-wide religions.
Common mistakes that cost hours
Naming events as commands.CreateOrder is a request; OrderCreated is a fact. Events are past-tense things that already happened and cannot be rejected. Get the tense wrong and your whole model gets muddy.
Putting business logic in projectors. Projectors translate events into views, nothing more. The moment a projector makes a decision, you have two places where rules live and they will drift.
Reading the read model to validate a write. It lags. Validate against the aggregate (the write side), never against an eventually-consistent projection.
Non-idempotent projections. If re-delivering an event double-counts, you can never safely replay or recover. Use upserts and absolute-set operations wherever you can.
No event versioning from day one. Retrofitting upcasters onto a stream of unversioned events is miserable. Stamp a version on every event before you ship.
Treating snapshots as truth. A corrupt snapshot must be throwaway. If you cannot delete every snapshot and rebuild from events, you are not really event-sourced anymore.
Takeaways
The whole article in seven lines
CQRS and event sourcing are separate patterns, you can use either alone.
CQRS splits the write model from purpose-built read models; reads serve projections.
Event sourcing stores the ordered log of events as truth and derives state by folding it.
The bank ledger is the mental model: append facts, never overwrite the balance.
The write and read sides are eventually consistent, design for the lag, do not fight it.
Version events from day one and use snapshots as a discardable rehydration cache.
Most domains should stay CRUD; event-source only the aggregates that truly need audit and time travel.
Where to go next
These patterns live next to a few others worth understanding together. The event stream that powers projections is the same backbone behind broader event-driven architecture on the cloud. The atomicity guarantees you give up when crossing the write/read boundary are the subject of database transactions and consistency. And because event sourcing is usually a per-service, per-aggregate decision, it pairs naturally with the boundary thinking in microservices vs monolith.
Pick one aggregate in your system that needs an audit trail and model its events on paper first, past-tense, immutable facts.
Prototype a single projection from that event stream and feel the eventual-consistency window for yourself.
Only then decide whether the rest of the system earns the same treatment. Usually it does not, and that is the right answer.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.