Learn how separating reads from writes and storing changes as events works, what projections and snapshots are, and when the extra complexity is not worth it.
What you'll learn: By the end of this lesson you will be able to explain CQRS and event sourcing as separate ideas, design an event store with projections and snapshots, reason about the eventual consistency of read models, handle replay and event schema changes, and decide when these patterns are not worth their cost.
Think about a bank account as a ledger rather than one number that is overwritten each time you spend money: deposit 500, withdraw 120, deposit 80. The balance is simply what you get by adding up the ledger. If there is a dispute, you can replay history. If an auditor wants to know the balance on 3 March, you add up entries up to that date.
Separately, the bank keeps a fast summary view for your app screen, because adding up ten years of entries on every page load would be silly. That summary is rebuilt from the ledger and is allowed to lag the ledger by a moment.
Those two ideas, a ledger of facts and a fast separate read view, are the heart of event sourcing and CQRS. They are powerful and also easy to over-use, so we will spend as much time on the costs as on the benefits.
The core idea
CQRS (Command Query Responsibility Segregation) means using separate models for changing data (commands) and reading data (queries). Event sourcing means storing every change as an immutable event, and deriving current state from the sequence of events.
These two patterns are independent:
You can do CQRS without event sourcing: write to a normalised relational database and maintain a denormalised search index or cache for reads.
You can do event sourcing without CQRS, though in practice reading current state by replaying events on every request is slow, so event-sourced systems almost always add read models.
They fit together naturally because events are an excellent feed for building read models.
Separating reads and writes (CQRS)
In a typical create-read-update-delete design, one model serves both purposes. That is simple and works well for most applications. Problems appear when reads and writes pull the model in opposite directions:
Writes want normalised data, strict validation, and transactions that protect invariants.
Reads want denormalised, pre-joined shapes tuned for each screen, often at many times the volume of writes.
Reads and writes scale differently, and one may need a different kind of store (relational for writes, a search engine or key-value store for reads).
CQRS splits the system into two sides.
A command expresses intent and can be rejected: PlaceOrder, CancelOrder. It returns success or failure (perhaps with an id or version), not a rich data view.
A query only reads and never changes state.
The write model enforces business rules. The read model is shaped purely for querying.
A projector keeps the read model updated from changes on the write side.
The price is a gap in time. Between a command being accepted and the read model reflecting it, a query may return old data. That is eventual consistency on the read side, and we return to it below.
NOTE
A lightweight form of CQRS needs no special infrastructure. A reporting database fed by a replica, or a materialised view, already separates read and write shapes. Start there before building a full command and query pipeline.
Event sourcing: the log is the truth
In a traditional design, the database row is the state; an update overwrites it, and history is lost unless you build an audit table. In event sourcing, the append-only event log is the source of truth, and the current state is a fold over it.
An event is a named fact in the past tense, with data and metadata:
Events belong to streams, typically one per aggregate (an order, an account, a cart). Each event in a stream has a sequence number. The event store supports two core operations: append events to a stream, and read a stream from the start (or from a position).
To handle a command:
Load the aggregate's events and rebuild its current state.
Check business rules against that state (can this order still be cancelled?).
If allowed, produce new events and append them.
Step 3 uses optimistic concurrency: you append "expecting version 7". If another writer has already added event 8, the append fails, and you reload and retry. That gives per-stream consistency without long-held locks.
sequenceDiagram
participant U as Client
participant H as Command handler
participant ES as Event store
U->>H: CancelOrder 4821
H->>ES: Read stream order-4821
ES->>H: Events 1 to 7
Note over H: Rebuild state, check rules
H->>ES: Append OrderCancelled, expect version 7
ES->>H: Appended as version 8
H->>U: Accepted
What you gain:
Benefit
Why it follows from the design
Complete audit trail
Every change is recorded, and with who, what and when metadata you get a full trail by construction
Time travel and debugging
Rebuild state at any past moment; reproduce a bug from real history
New read models later
Add a new projection and rebuild it from historical events
Natural integration
Events can be published to other services
Captures intent
AddressChanged says more than an overwritten column
Projections: building read models from events
A projection is a function that consumes events and maintains a read-optimised view. One event stream can feed many projections: an "orders by customer" table, a search index, a dashboard counter, a data warehouse feed.
Each projector remembers a checkpoint (the last event position it processed). After a crash it resumes from the checkpoint. Because delivery can repeat events, projections must be idempotent: applying the same event twice must not corrupt the view. Common approaches are upserts keyed by entity id, or storing the last processed version with the row and ignoring events at or below it. This echoes the idempotency discussion in Distributed Transactions.
Projections are disposable. If a view has a bug, or a new query needs a new shape, you fix the projector, delete the view and rebuild it by replaying the log. That is a major operational advantage, but it has a cost: replay of a long history can take hours, so plan for it, ideally by building the new version side by side and switching traffic when it catches up.
Snapshots: keeping rebuild fast
Rebuilding an aggregate from its first event is cheap for an order with ten events. For an account with a million events, it is not. A snapshot is a saved copy of an aggregate's state at a given version. To load, read the latest snapshot and replay only the events after it.
Snapshots are a performance cache, not a source of truth. You can always discard and recreate them from the log. Typical policy: take a snapshot every N events (for example, every few hundred), or when loading takes too long. Do not add snapshots until you measure a problem. Many aggregates stay small enough that they never need them, and well-designed aggregates have short lifetimes (a shopping cart, an order) rather than infinite ones.
TIP
If an aggregate's stream grows without bound, check the modelling first. A "customer" that accumulates every event ever may really be many smaller aggregates, such as separate orders, with the customer holding only references.
Eventual consistency on the read side
Because projections run after the write, the read model lags. Usually the lag is milliseconds, but during load spikes or a projector outage it can be seconds or minutes. The classic symptom: a user submits a form, is redirected to a list page, and does not see their change.
Technique
How it works
Trade-off
Return the result from the command
Command response includes the new state or id, and the UI shows it directly
Simple; covers the common "post then redirect" case
Optimistic UI
The client shows the expected result immediately and reconciles later
Needs care when the command may be rejected
Read-your-writes by version
Command returns a version; the query waits until the read model reaches it
Adds latency and plumbing; needs per-view checkpoints
Read from the write side for that user
Route a user's reads for recent changes to the source of truth
Increases load on the write side
Set expectations
Show "processing" states in the UI
Honest, but needs product buy-in
Decide, feature by feature, how stale each read is allowed to be. A product listing can lag seconds. A "balance after transfer" screen probably should not. For the consistency vocabulary, revisit CAP and Consistency Models.
Event schema evolution
Events are immutable and may live for years, so the code that reads them must handle every shape they ever had. Requirements change, and so will events. Strategies:
Additive changes first. Adding an optional field is usually safe: old events lack it, readers use a default. Never reuse a field name for a different meaning.
Versioned event types. Introduce OrderPlacedV2 and keep a handler for OrderPlacedV1.
Upcasting. On read, transform old events into the latest shape in code, so the rest of the system sees only the current version. The stored event stays untouched.
Explicit schemas with a registry. Use a schema format with explicit compatibility rules (for example Avro, Protobuf or JSON Schema with a schema registry) and enforce backward compatibility in CI.
Copy-and-transform migration. For a breaking change, write a new stream (or a new store) from the old events, transformed, then switch over. This is heavy, and is a reason to design events carefully.
Two further points deserve care:
Events are facts, not commands. Name them in past tense and record what happened, not what the next step should do.
Privacy and deletion. An append-only log conflicts with "delete my data" obligations. Common approaches are keeping personal data out of events (store a reference), encrypting personal fields with a per-subject key that can be destroyed (sometimes called crypto-shredding), or keeping personal data in a mutable store. Decide this before launch, because retrofitting is painful.
When the cost is not worth it
CQRS and event sourcing are not defaults. They add moving parts: an event store, projectors, checkpoints, rebuild procedures, versioned schemas, and developers who must think in a different style. Each of those has to be operated and debugged.
Situation
Better choice
Why
Simple CRUD with modest load
Plain relational model
Fewer parts; consistency is trivial
Need an audit trail only
Audit table or change data capture
Gives history without making events the source of truth
Reads heavier than writes, same data shape
Read replicas and caching
No second model to maintain
Search or analytics needs a different shape
CQRS-lite: replica, materialised view or search index
Separate read model without event sourcing
Strong consistency required everywhere
Single transactional store
Eventual consistency would violate the requirements
Team new to the pattern, tight deadline
Defer
Mistakes in event design are expensive to undo
flowchart TD
Q1{"Is history itself a requirement?"} -->|yes| ES["Event sourcing for that area"]
Q1 -->|no| Q2{"Do reads need a different shape or store?"}
Q2 -->|yes| Q3{"Can a replica or materialised view serve them?"}
Q3 -->|yes| LITE["CQRS-lite with a replica or view"]
Q3 -->|no| CQ["CQRS with a separate read store"]
Q2 -->|no| CRUD["Plain CRUD model"]
Read it as a series of cheaper exits: most systems leave at plain CRUD or CQRS-lite.
Event sourcing earns its keep when the history itself is the product or a regulatory need (ledgers, trading, order lifecycles with complex state), when you need many different views of the same facts, or when you must reconstruct past states. Even then, apply it to the parts of the system that benefit, not everything. Systems that use it often confine it to one or two bounded areas next to ordinary CRUD services.
WARNING
Event sourcing is not a message bus, and an event store is not automatically a good general-purpose broker. Publishing events to other services is a separate concern, and you should still use patterns like the outbox so that writing the event and announcing it do not diverge. See Event Streaming for the log-based tools often used to carry them.
In practice: a digital-wallet ledger service
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A consumer wallet app supports top-ups, peer-to-peer transfers and merchant payments for about 1.5 million users (assumption). Twelve engineers run Java services on Kubernetes with PostgreSQL, Kafka, Redis and an OpenSearch cluster for transaction search. Today a balances table is updated in place next to a transactions table. The numbers that hurt (illustrative): a weekly reconciliation finds roughly 0.3 percent of accounts where the balance and the transaction sum disagree, the month-end statement queries push balance-read p99 to around 900 ms, and auditors ask for "the balance of account X at close of business on 31 March", which the team answers with ad hoc scripts.
Starting architecture
Two tables are written separately, so they can drift, and heavy read traffic lands on the same database as money movement.
Options considered
Option
What it fixes
What it costs
Risk
A. Smaller change: add an audit table and a read replica
Statement load, partial history
Days
The drift bug and as-of queries remain
B. Append-only ledger as the source of truth in PostgreSQL, with projections for balances, statements and search
Drift, history, as-of queries, read load
A quarter of work; projector operations
Team learns event design; read lag is visible
C. Adopt a dedicated event store product and full CQRS across all wallet features
Same as B, with purpose-built tooling
New infrastructure and skills
Large blast radius for a 12-person team
The decision
Field
Entry
Context
Money needs an immutable history and a balance that is always explainable; reads and writes have very different shapes.
Decision
Adopt B for the ledger only. Each account is a stream of entries (version number, amount in minor units, currency, transfer id). A unique key on account id and version gives optimistic concurrency. A transfer writes both entries, plus an outbox row for Kafka, in one local transaction. Debit decisions are made from the account's own entries (add a snapshot only if replay becomes slow), not from the lagging read model.
Consequences
Good: balance equals the sum of entries by construction, any past balance is a query, new views can be backfilled. Bad: statements and search lag by seconds, and event schemas must be versioned for years.
Revisit when
Replaying one account takes longer than the debit latency budget, or a full projection rebuild exceeds the maintenance window.
erDiagram
ACCOUNT ||--o{ LEDGER_ENTRY : "has stream of"
ACCOUNT ||--|| BALANCE_VIEW : "summarised by"
ACCOUNT ||--o{ STATEMENT_LINE : "listed in"
ACCOUNT {
string id PK
}
LEDGER_ENTRY {
string account_id FK
int version
string type
int amount_minor
string currency
string transfer_id
timestamp occurred_at
}
BALANCE_VIEW {
string account_id PK
int balance_minor
int last_version
}
STATEMENT_LINE {
string account_id FK
string transfer_id
timestamp occurred_at
}
The unique pair (account_id, version) is the whole concurrency mechanism: a second writer with the same expected version fails and retries.
Rollout and verification
flowchart LR
BF["Backfill ledger from history"] --> SH["Shadow period both models written"]
SH --> CMP["Nightly comparison of balances"]
CMP --> CUT["Cut over by cohort"]
CUT --> RET["Retire old balances table"]
Backfill generates entries from the existing transactions. Then the service writes to both models while a nightly job compares the old balance with the folded ledger for every account. Cut over by cohort (staff, then 5 percent, then the rest) once the comparison shows zero differences for an agreed period such as 14 days. Dashboards: projection lag (p99 target of a couple of seconds), count of comparison mismatches (target zero), optimistic-concurrency retries per minute, and rebuild time measured in a rehearsal.
The 2 a.m. failure
The page reads: "statements projection lag above 5 minutes". A newly released event type reached the statements projector, which cannot parse it, retries forever and holds its checkpoint. Debits still work, because they use the write side.
sequenceDiagram
participant L as Ledger
participant K as Kafka
participant P as Statements projector
L->>K: Outbox relay publishes new entry type
K->>P: Deliver event
P->>P: Parse fails
P->>K: No commit, retry
Note over P: Checkpoint stuck, lag grows
P->>K: After fix, resume from checkpoint
Detection: projection_lag_seconds{projection="statements"} and a repeating parse error in the logs. First three checks: read the consumer group lag (for example with kafka-consumer-groups.sh --describe --group <group>), find the offset that keeps failing, and inspect that event against the schema. Mitigation: deploy a projector that tolerates the new field, or route the single bad event to a dead-letter topic with a ticket and backfill that statement line once the projector is fixed. Meanwhile the app shows the transfer from the command response. Follow-up: a CI check that replays recorded events through every projector, and a compatibility gate before a new event type ships.
Architect's notes
Split the decision by need. Use event sourcing where history is a requirement (the ledger), and plain tables for profiles, settings and notifications.
Money decisions read the write side. Any check that must be right, such as "enough balance", must not depend on an eventually consistent view.
Ask in the review: "How long does a full rebuild take, and what happens to users meanwhile?" Rehearse it before you need it.
Cost trap. Event schemas outlive the team that wrote them. Budget for upcasters and compatibility tests from day one.
Privacy. Decide early which personal fields never go into events.
Defer on purpose. Snapshots and a dedicated event-store product can wait until measurements demand them.
Common mistakes
Using the patterns by default. Both add real operating cost. Start with a simple model and adopt them where a concrete need (audit, many views, history) justifies it.
Confusing CQRS with event sourcing. They are separate. Adopt only the part you need; CQRS-lite with a replica or search index often suffices.
Non-idempotent projections. Replays and retries will double-count. Key updates by entity and version, and test by replaying the same events twice.
Ignoring read-side lag in the user experience. Users think the write failed. Return results from commands, use optimistic UI, or wait on versions where it matters.
Careless event design. Events named like database operations (RowUpdated) lose intent and are hard to evolve. Model business facts, and plan for versioning from the start.
Unbounded streams and no snapshot plan. Loading becomes slow. Model smaller aggregates, and snapshot only when measured need arises.
Forgetting data deletion rules. An immutable log and privacy law collide. Keep personal data out of events or use encryption keys you can destroy.
Key takeaways
CQRS separates intent from view. Commands change state and can be rejected; queries read optimised models.
Event sourcing stores facts. The append-only log is the source of truth, and current state is derived by folding events.
The two ideas are independent. Many systems use CQRS alone; event sourcing almost always needs read models.
Projections are disposable. Rebuild them by replay, keep them idempotent, and checkpoint their position.
Snapshots are caches. Add them only after measuring slow loads, and never treat them as authoritative.
Read models are eventually consistent. Design the user experience around lag with command responses, optimistic UI, or version waits.
Events live forever, so evolve them carefully. Prefer additive changes, upcasting and enforced compatibility, and plan for privacy deletion.
Use sparingly. Apply these patterns where history or many views matter, and keep ordinary CRUD where it suffices.
Practice exercises
Exercise 1 — Starter (30 min): For a bank account, list the commands and events you would define (at least four events, past-tense names). Then compute the balance by folding this sequence: Opened(0), Deposited(500), Withdrew(120), Deposited(80), Withdrew(60).
Exercise 2 — Intermediate (2 hours): Design an event-sourced order aggregate with the events OrderPlaced, PaymentReceived, OrderShipped and OrderCancelled. Write the rules for which commands are valid in which state (a small state diagram helps), show how optimistic concurrency stops two simultaneous commands from both succeeding, and define two projections with an idempotency approach for each.
Exercise 3 — Advanced (half day): Your OrderPlaced event must change: the single address string becomes a structured address with separate fields, and personal details must be removable on request. Propose a migration using upcasting, then a second using a copy-and-transform rebuild. Compare them on risk, downtime, replay time and storage. Describe how you would keep personal data deletable, and define the rollout plan, including how you would rebuild a projection side by side and cut over safely.
Related reading
Distributed Transactions - outbox and idempotency, the techniques that make event publishing reliable.
Event Streaming - the log-based infrastructure often used to distribute events to projectors.
Storage Engines - append-only logs and why they are fast to write.
Enroll to keep reading
Lesson 3 of 14 · Advanced System Design and Case Studies
Learn how separating reads from writes and storing changes as events works, what projections and snapshots are, and when the extra complexity is not worth it.
In the rest of this lesson
Projections: building read models from events
Snapshots: keeping rebuild fast
Eventual consistency on the read side
Event schema evolution
When the cost is not worth it
In practice: a digital-wallet ledger service
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.