Primary Sources
Specifications and documentation behind the claims on this page
- Greg Young, “CQRS Documents” (2010) — the originator of the CQRS term (no link given)
Core Concepts
Separate read and write responsibilities so each can be optimized independently.
Two models, because deciding and answering are different jobs
Command Query Responsibility Segregation (CQRS) separates the model that changes application state from the model that reads it. A command expresses an intent, such as ApproveOrder, and is validated against business rules before it commits. A query returns information and is not meant to change business state. The two sides may differ in API, data structure, or storage technology because they optimise for different work.
CQRS does not require two databases, microservices, asynchronous events, or event sourcing. The smallest useful form is a command handler and a query handler over the same relational database. The larger form keeps normalised transactional records on one side and denormalised read projections on the other. The extra machinery is justified only by a concrete mismatch between what writes and reads need.
Think of an order service. The decision model exists to say yes or no: is this order paid, not cancelled, and eligible to ship? It needs the current authoritative facts, transactional integrity, and a way to stop two actors from making conflicting decisions. The answer model exists to show things quickly: a customer's latest orders with thumbnails, shipping state, and payment status in one lookup. It wants whatever shape makes the question cheap, even if that shape duplicates data.
Forcing both jobs through one schema makes each compromise. Normalised tables that protect invariants are awkward to query; wide, denormalised rows that are cheap to read are dangerous places to enforce rules. CQRS makes the split deliberate. The write side stays the source of truth. The read side is a projection: derived, disposable, and rebuildable from authoritative changes.
The idea extends command-query separation, which Bertrand Meyer applied to individual methods, up to whole models. The rest of this chapter follows one order service as it grows: how the command side protects invariants, how projections stay in sync, what the user sees while they lag, how they are rebuilt, and when all of this is not worth it.
Single model bottleneck → separate commands and queries → projector synchronization → scalable reads
⤢ Expand for synchronized narration timeline
A measured mismatch, not a default architecture
Writes in the order service must validate inventory, enforce payment rules, prevent duplicate transitions, and stay transactionally consistent. Reads are different: a dashboard wants each customer's latest orders, shipping state, item thumbnails, and payment status. If every dashboard request joins several high-volume tables, the query path can become expensive and hard to scale, and it competes with the writes for the same buffer cache, locks, and I/O. A read projection precomputes the shape the dashboard needs without weakening the transactional rules on the write side.
The opposite case matters just as much. If the service has a handful of tables and ordinary indexed queries meet the latency goals, an asynchronous projection is a net loss: you pay for a pipeline, lag, and rebuild procedures to solve a problem you do not have. CQRS is a response to a measured problem, which is why it helps to see it as a ladder rather than a switch.
At level 1 only the code is split: separate command and query handlers or modules, one database. It costs almost nothing, and it already gives the benefits that come from clear intent, such as query code that cannot accidentally mutate and command code that is not shaped by what a screen needs. At level 2 the database platform supplies read-optimised structures: covering indexes, materialized views, or read replicas. Be honest about what they cost. A Postgres materialized view is only as fresh as its last REFRESH, and a replica lags the primary, so the read-after-write question below appears even without CQRS. At level 3 a separate read store is fed by changes from the write side. That is the form most people mean by CQRS, and the one with the most operational surface.
Climb only as far as the problem requires. If ordinary indexing, caching, or a materialized view meets the requirement with fewer moving parts, stop there. Moving read load elsewhere is a scaling technique; independent read shapes (full-text search, per-customer documents, precomputed aggregates) are the stronger reason for level 3.
A command is a business decision, not a write-shaped HTTP request
ShipOrder(orderId) is not "set status to SHIPPED". It is a request that the system first checks against the rules: the order must be paid, not cancelled, and eligible to ship. Commands are phrased as intent and can be rejected; the events they may produce are phrased in the past tense and cannot. A command handler loads the entity or aggregate it needs, decides, and commits an allowed transition, usually with optimistic concurrency:
UPDATE orders
SET status = 'SHIPPED',
version = version + 1
WHERE order_id = :order_id
AND status = 'READY_TO_SHIP'
AND version = :expected_version
RETURNING version;The WHERE clause is the decision made atomic. The handler read the order at version 6, decided that shipping was allowed, and the update succeeds only if nothing has changed since. If zero rows come back, the command was based on stale information or is no longer valid, and it must not be reported as a successful shipment. Note that the version check is stricter than the status check: an unrelated change such as an address edit also bumps the version, so a rejection means "reload and decide again", not necessarily "the order cannot ship".
Both handlers saw version 6. The database serialises the two updates; the first matches and moves the row to version 7, the second now finds version = 6 false and matches nothing. Without the guard, the second command would apply a decision made on a state that no longer exists.
Clients retry after timeouts, and a retried ShipOrder reaches the guard in a world where the first attempt may have already succeeded, so it gets zero rows. Rejecting it would be wrong from the client's point of view: the shipment did happen. Give each command a unique identifier or idempotency key, record it in the same transaction as the state change, and answer a repeat with the stored result. The Idempotency lesson covers the key lifecycle; the shape here is:
def handle_ship_order(cmd): # cmd.command_id, cmd.order_id
with transaction():
done = commands.find(cmd.command_id)
if done: return done.result # retry of an applied command: same answer
order = orders.load(cmd.order_id)
if order.status != "READY_TO_SHIP":
raise Rejected("not shippable")
rows = orders.update_if_version(order, status="SHIPPED") # guarded UPDATE above
if rows == 0:
raise Conflict("order changed; reload") # client may retry with fresh state
outbox.insert("OrderShipped", order.id, order.version + 1)
result = {"order_id": order.id, "version": order.version + 1}
commands.insert(cmd.command_id, result)
return resultThe response carries the new version. Commands are often described as returning nothing, but returning an acknowledgement, an identifier, or the resulting version is useful and harmless; it is query data for arbitrary screens that does not belong in a command response. That version is also the token that later makes read-your-writes possible.
A version column protects one row or one aggregate. Rules that span aggregates, such as "do not oversell inventory shared by many orders", need a transaction, a constraint, or an explicit coordination mechanism such as a saga. Either way, enforcement belongs at the authoritative boundary. A fast read model must never become the source of truth for inventory, balances, or authorization. It can show "3 left" as advice; the command side re-checks when the customer actually buys.
Projections are derived data, shaped by the question
A read model might be a document per order containing exactly the fields the dashboard shows, or a materialised relational table indexed by customer and recency. It can join and denormalise data from several write-side tables and expose a trivial query such as GET /customers/42/orders. What makes it a projection is its provenance: it is built from authoritative changes and can be rebuilt or repaired from them. Treat it as an independent source of business truth only if that is an explicit part of the design.
CREATE TABLE order_summaries (
order_id text PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
item_count int NOT NULL,
total_cents bigint NOT NULL,
placed_at timestamptz NOT NULL,
last_version int NOT NULL -- last write-side version applied
);
CREATE INDEX order_summaries_customer_recent
ON order_summaries (customer_id, placed_at DESC);
-- GET /customers/42/orders: one index range scan, no joins
SELECT order_id, status, item_count, total_cents, placed_at
FROM order_summaries
WHERE customer_id = 42
ORDER BY placed_at DESC
LIMIT 20;The last_version column is not decoration. It records how far this row has caught up with the write side, and the next sections use it for deduplication, ordering, and read-your-writes. Because the shape is tailored to one access pattern, the same write model can feed several projections: a customer dashboard, a search index, a finance report. Each can use the store that suits it, and the result is that keyset pagination or full-text ranking can be designed into the read model rather than bolted onto a transactional schema.
One rule keeps this safe: the query side makes no business decisions. Once a projector or query handler starts deciding what an order "really" is, you have two interpretations of the domain, a command side that says ACTIVE and a read side that says PENDING. Derive, do not decide. Several projections also advance independently, so a dashboard and a search page can briefly disagree. That is a property of the design to communicate, not a bug in either projection.
From command to committed state to read model, and where the lag lives
The most common level-3 pipeline uses the transactional outbox. The figure marks the one window the pattern introduces, between commit and read visibility, and the path that repairs or rebuilds the read store.
Steps 1 and 2 are the command's latency; steps 3 to 5 are the lag. The mechanics and failure modes of the relay are owned by Transactional Outbox, and fan-out to multiple projectors is the subject of Pub/Sub. What this lesson owns is what the projector must do with what arrives.
At-least-once delivery means the projector must be idempotent and order-aware
A relay publishes after the commit, so a crash between publishing and recording that fact produces a duplicate: delivery is at-least-once. Brokers can also reorder events across partitions or after retries. The projector therefore cannot assume "each event once, in order". Its update must tolerate duplicates, and it needs a rule for events that arrive early.
The standard technique is to record the last processed event version for each aggregate. An event with a version less than or equal to the stored one is a duplicate or stale and is ignored. An event whose version is exactly one more is applied. An event further ahead means something is missing: the projector must buffer it, fetch the missing changes, or fall back to rebuilding that aggregate from authoritative state. Try each case below, and switch to the naive rule to see what an unguarded "overwrite whatever arrives" projector does.
v3 overtakes v2, for example across partitions or after a retry.
| # | Delivered | Decision | Row after | Parked |
|---|---|---|---|---|
| 1 | v1 OrderPlaced | applied | PLACED @ v1 | — |
| 2 | v3 OrderShipped | parked: waiting for v2 | PLACED @ v1 | v3 |
| 3 | v2 PaymentCaptured | applied, then drained v3 from the buffer | SHIPPED @ v3 | — |
The reordered case is the instructive one. A projector that simply sets status from each event is idempotent against duplicates, yet it ends at PAID after the order shipped, because v2 arrived after v3. Idempotency alone is not enough; ordering has to be part of the rule. A bare timestamp is a poor substitute for a per-aggregate sequence: it relies on clocks across different nodes and cannot tell a missing event from a quiet aggregate, so it does not establish causality.
-- OrderPlaced (v1): create the row; a duplicate is a no-op
INSERT INTO order_summaries
(order_id, customer_id, status, item_count, total_cents, placed_at, last_version)
VALUES (:order_id, :customer_id, 'PLACED', :item_count, :total_cents, :placed_at, 1)
ON CONFLICT (order_id) DO NOTHING;
-- Any later event: apply only the next version
UPDATE order_summaries
SET status = :new_status, last_version = :v
WHERE order_id = :order_id
AND last_version = :v - 1;
-- 1 row -> applied
-- 0 rows -> duplicate/older (last_version >= :v) or gap (last_version < :v - 1);
-- SELECT last_version to tell which, then ignore, park, or repairTwo refinements matter in practice. First, when the read store is transactional, write the row update and the consumer's checkpoint in the same transaction, so a crash cannot apply an event without recording it. When the store is not (a search index, say), rely on the version guard and make writes naturally idempotent. Second, the shape of the event decides how strict the rule must be. State-carrying events that contain the full new values can use the looser guard WHERE last_version < :v (last write wins by version) and tolerate gaps, at the cost of larger events. Delta events such as "add 10 to the total" need the contiguity rule above, or a dedup table keyed by event id; applying total = total + 10 twice doubles the total.
Some systems skip the outbox and use change data capture directly on the database log. CDC removes custom publisher code, but it does not define business meaning: a row update is not always a meaningful domain event, and renaming a column silently changes your public contract. Whether you use an outbox or CDC, write down the contract: event names, schema versions, and the ordering scope (typically per aggregate, enforced by using the aggregate id as the partition key). A guarantee of order within a partition says nothing about order across aggregates.
A projector that fails on a poisoned event must not block the stream forever or drop it silently. Retry with bounded backoff, then move the event to a dead-letter queue and treat the affected aggregates as stale until repaired. The gap rule above is exactly how you notice that something was dead-lettered.
The lag is a user-experience contract, not only an infrastructure number
With asynchronous projection, a command can commit at 12:00:00.000 while the read model updates at 12:00:00.250. A user who refreshes the dashboard in between sees the old state. That is not automatically a bug; it is the consequence of the consistency model you chose. The product still has to decide whether that experience is acceptable, and if not, which technique closes the gap for the flows that matter.
Do not promise read-your-writes consistency merely because the pipeline is fast on average. A failed index node, a deploy of the projector, a partition, or a backlog spike turns 250 ms into minutes, and those are exactly the moments users refresh. Whatever technique you use must stay correct when the lag is long.
Four techniques, in rough order of how little they depend on the projection:
last_version for that entity, waits up to a short budget for it to catch up, and otherwise falls back or returns a pending marker. This works per entity. A list query cannot use an entity-level version when the row it is waiting for does not exist yet, so for lists either merge the command response on the client or track a per-customer or per-stream position.def get_order(order_id, min_version=None, wait_budget_ms=1500):
deadline = now_ms() + wait_budget_ms
while True:
row = read_store.get(order_id)
if min_version is None or (row and row.last_version >= min_version):
return row
if now_ms() >= deadline:
# still behind: fall back narrowly, or tell the client it is pending
return write_store.get_order_summary(order_id) # or: Pending(min_version)
sleep_ms(50)The wait budget must be small relative to the caller's deadline, and the fallback must be a deliberate choice. A wait without a bound converts a projection problem into a request-latency problem for every user. The same reasoning applies to the read replicas of level 2: replica lag creates exactly the same stale-read window, and the same techniques apply.
Read models change with the product; rebuild them without downtime
Read models change as requirements change. A new dashboard needs a field the projection never stored; a bug in the projector corrupted a month of rows; the read store itself is being replaced. The rebuild must not take reads offline. The safe pattern is blue/green: build the new projection beside the old one, bring it up to date, check it, and only then switch reads.
P inside the same snapshot used for the backfill (for example, a repeatable-read transaction that reads the outbox high-water mark before scanning).P, or replay retained events from the start. Rows carry their versions, so the guard from the previous section still applies.P accumulated in the stream. Start slightly before P to be safe: the version guard makes the overlap harmless, and a sequence value that committed out of order cannot be silently skipped.If the event history is incomplete, or events were not designed for replay, the write database has to be the backfill source. Replay also has a hard safety rule: replaying events is not safe if the projector causes external side effects. A projector that only derives read state can run any number of times. One that sends an email or charges a card must not be part of a replay path; side effects belong in separate reactors with their own deduplication.
Version your event schemas and your projection code deliberately. Add fields as optional where possible so old events remain readable, record the projection version alongside its data, and keep the contract between the two explicit. How stored events are upgraded when their shape changes (upcasting) is covered in Event Sourcing; here the point is that a projector must handle every event version it can still receive.
A healthy command service with a broken projection is still a user-visible incident. Track event age (how old the oldest unapplied change is), processing lag by partition, dead-lettered or parked events, count mismatches between the write and read stores, and rebuild progress. Prefer "age of the oldest unapplied change" over "time since the last event was processed": a stuck consumer on a quiet stream processes nothing and can look perfectly idle. Periodic reconciliation, comparing committed entities with their projections, catches the cases lag metrics cannot, such as an event that was never published.
One splits models; the other changes what the write side stores
CQRS separates the responsibilities of commands and queries. Event sourcing stores state changes as an event history and reconstructs state from it. You can use CQRS with an ordinary relational write model and a search index as the read side. You can also use event sourcing while exposing a simple query API from the same process, loading an aggregate by replaying its events. The two are frequently combined because events are convenient inputs to projections, but neither implies the other.
The practical consequence is that adopting CQRS never obliges you to adopt an event store, and an event store gives you no read-model scalability on its own. How replay, snapshots, expected-version concurrency, and upcasting work for the event history is the subject of the Event Sourcing lesson. If you only need an audit trail, an append-only history table written next to the state may be enough.
A constructed scenario: committed, confirmed, and missing from the list
A customer completes checkout, receives order ID o_924, and immediately opens the order list. The command transaction succeeded. But the projector is 30 seconds behind because a search index node failed, so the dashboard says "No recent orders".
The wrong reaction is to treat this as a checkout failure and retry or tell the customer to try again; that risks a duplicate order, and the order is not lost. A better product flow does four things. The client merges the order from the command response into the list and marks it as syncing. The system alerts on projection lag, using the age of the oldest unapplied change. On-call repairs the index node and lets the projector resume from its checkpoint; the version guard makes the replayed backlog safe to apply. Finally, a reconciliation job compares committed orders with the read store and repairs any aggregate the replay missed.
Note the 30 seconds. Nothing in the architecture made it small: only monitoring and a deliberate fallback bound how long a user can be confused. That is why eventual consistency belongs in the product specification, not only in the infrastructure runbook.
Start with the cheapest form that removes the measured problem
Reach for separate models when:
Avoid it, or stop at level 1 or 2, when:
A traditional single model is simpler to build and always reads current data; CQRS trades that simplicity and immediacy for read shapes and isolation that suit each side. Neither is better in the abstract. Start with separate handlers or modules, then add read structures or a separate store when a measurement says the single model is the bottleneck.
The neighbouring lessons that own the parts this chapter relies on
Transactional Outbox explains how a commit and its event stay atomic, and Pub/Sub covers distributing those events to several projectors. For how the event history itself is stored, replayed, and snapshotted, continue with Event Sourcing. When a command spans several services, see Saga Pattern. Projector failures are handled with Retry & Backoff, Dead Letter Queues, and Backpressure when a backlog builds.
Specifications and documentation behind the claims on this page