See It Flow

Core Concepts

CQRS

Separate read and write responsibilities so each can be optimized independently.

Mangesh MandlikLast updated:

What Is CQRS?

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.

The mental model: a decision model and an answer model

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.

Visual Story

Single model bottleneck → separate commands and queries → projector synchronization → scalable reads

CQRS

CQRS
Shared Model
0 / 0
🔀Up nextSeparate Paths

⤢ Expand for synchronized narration timeline

Why One Model Becomes Awkward

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.

Three levels of CQRS, from split code to a separate read storeLevel 1 splits command and query handlers over one database, so reads are immediately consistent. Level 2 adds read-optimised structures such as materialized views or read replicas inside the database platform; they are refreshed or replicated asynchronously and can lag. Level 3 adds a separate read store fed by an outbox, relay, and projection worker, giving independent read shapes at the cost of an asynchronous pipeline. Arrows point from caller to the component it calls or feeds; amber arrows are asynchronous.Level 1: separate command and query handlers, one databaseCommand handlervalidate + writeDatabaseone schemaQuery handlerreads directlyReads see every committed write, subject to the isolation level.No lag and no new infrastructure; only the code is split.Level 2: same database platform, read-optimised structuresCommand handlervalidate + writePrimary tablesnormalisedView / replicamatview, replicaQuery handlershaped readsMoves read work off the write path, but replicas lag and materialized viewsare only as fresh as their last refresh.Level 3: separate read store fed by changes from the write sideCommand handlervalidate + writeWrite store+ outboxProjectorrelay + workerRead storeprojectionsQuery handlershaped readsRead shapes are independent of the write schema, at the price of a pipelinewhose lag, failures, and rebuilds you now operate.
  • Synchronous call
  • Asynchronous hop (lag)
Three levels of CQRS, from split code to a separate read store. Level 1 splits command and query handlers over one database, so reads are immediately consistent. Level 2 adds read-optimised structures such as materialized views or read replicas inside the database platform; they are refreshed or replicated asynchronously and can lag. Level 3 adds a separate read store fed by an outbox, relay, and projection worker, giving independent read shapes at the cost of an asynchronous pipeline. Arrows point from caller to the component it calls or feeds; amber arrows are asynchronous.

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.

The Command Side: Protecting Invariants

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:

Guarded transition (PostgreSQL syntax; assumes an orders table with these columns)
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".

Two ShipOrder commands race on one order versionBoth handlers load order o_924 as READY_TO_SHIP at version 6. The first conditional UPDATE matches one row and moves the order to version 7. The second UPDATE still says version = 6, matches zero rows, and the handler rejects the command so the caller can reload and decide again.Client AHandler 1Orders DBauthoritativeHandler 2Client B1. ShipOrder o_9242. load order3. READY_TO_SHIP, v64. ShipOrder o_9245. load order6. READY_TO_SHIP, v67. UPDATE … version = 68. 1 row → v79. shipped (v7)10. UPDATE … version = 611. 0 rows12. rejected: reload
Two ShipOrder commands race on one order version. Both handlers load order o_924 as READY_TO_SHIP at version 6. The first conditional UPDATE matches one row and moves the order to version 7. The second UPDATE still says version = 6, matches zero rows, and the handler rejects the command so the caller can reload and decide again.

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.

Retries and command identity

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:

Command handler with deduplication (illustrative pseudocode)
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 result

The 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.

Where the guard stops

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.

The Query Side: Optimised for Access Patterns

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.

A read-model table for the dashboard (PostgreSQL syntax; illustrative schema)
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.

End-to-End Data Flow

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.

CQRS data flow with the consistency lag and the rebuild pathCommands go from the client to the command handler (1), which validates and commits to the transactional write store together with an outbox row (2). An outbox relay reads committed changes (3) and publishes them (4); a projection worker applies them to the read store (5). Queries go from the client to the query handler (6) and are answered from the read store (7). Steps 3 to 5 are asynchronous, so there is a lag between commit and read visibility. A rebuild or reconcile job can refill the read store from the write store or from retained events.Clientbrowser / appCommand handlervalidate rulesWrite storestate + outbox rowOutbox relaypublish committed rowsProjection workeridempotent, versionedRead storedenormalised projectionsQuery handlerno business rulesRebuild / reconcile1234567rebuild path: backfill fromthe write store or replayretained eventslag betweencommit andread visibilitySteps 3–5 are asynchronous: a successful command does not mean the read model has caught up.
  • Inside the request
  • Asynchronous hop (adds lag)
  • Rebuild / repair path
CQRS data flow with the consistency lag and the rebuild path. Commands go from the client to the command handler (1), which validates and commits to the transactional write store together with an outbox row (2). An outbox relay reads committed changes (3) and publishes them (4); a projection worker applies them to the read store (5). Queries go from the client to the query handler (6) and are answered from the read store (7). Steps 3 to 5 are asynchronous, so there is a lag between commit and read visibility. A rebuild or reconcile job can refill the read store from the write store or from retained events.
  1. The client sends a command to the command handler.
  2. The handler validates the rules and commits the state change and an outbox row in one local transaction. Either both exist or neither does.
  3. The outbox relay reads committed rows it has not yet published.
  4. The relay publishes each change to the stream or queue the projector consumes.
  5. The projection worker applies the change to the read store.
  6. The client later sends a query to the query handler.
  7. The query handler answers from the read store, never from the write 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.

Keeping the Read Model in Sync

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.

Delivery
Projector rule
3

v3 overtakes v2, for example across partitions or after a retry.

Delivery of v1, v3, v2 for order o_924 under the version-guarded rule (apply only version last+1, ignore older, park newer). v3 overtakes v2, for example across partitions or after a retry.
#DeliveredDecisionRow afterParked
1v1 OrderPlacedappliedPLACED @ v1—
2v3 OrderShippedparked: waiting for v2PLACED @ v1v3
3v2 PaymentCapturedapplied, then drained v3 from the bufferSHIPPED @ v3—
Projector delivery rules. The same deliveries applied by a version-guarded projector and by a naive one. Rose rows mark a read model that went backwards. Illustrative events for one order.
Read model status
SHIPPED
Applied through
v3
Parked events
none
Verdict
correct so far

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.

Version-guarded projection (PostgreSQL syntax; runs against the order_summaries table above)
-- 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 repair

Two 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.

Change data capture is not a domain event

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.

Eventual Consistency and Read-Your-Writes

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.

3 s
1 s
1.5 s
How lag, read timing, and a wait budget decide what the user seesCommand commits at 0 s. The projection becomes visible at 3 s. A plain read at 1 s sees the old state. A version-aware read at 1 s with a 1.5 s wait budget is still stale after 1.5 s: falls back. Illustrative values.Command commitst = 0Read model stalevisible at 3 sPlain readsees the old stateVersion-aware readfalls back0 s3.3 s
  • Read model stale
  • Fresh result
  • Stale result
  • Waiting for the projection
How lag, read timing, and a wait budget decide what the user sees. Command commits at 0 s. The projection becomes visible at 3 s. A plain read at 1 s sees the old state. A version-aware read at 1 s with a 1.5 s wait budget is still stale after 1.5 s: falls back. Illustrative values.
Plain read
old state
Version-aware read
fallback
Added latency
1.5 s
Command response
always new
for the entity it returns

Four techniques, in rough order of how little they depend on the projection:

  • Return the new state from the command. The response already contains the order the user just created. The client shows it, merging it into any list it renders. This is independent of lag but covers only the entity the command returns.
  • Show an honest in-between state. "Processing" or "Confirming your order" with the confirmed data is better than a screen where the user's action seems to have vanished. Users who think their action was lost resubmit, which is how duplicate commands appear, so pair this with command idempotency.
  • Wait for a known version. The command response includes the version it produced; the client passes it back as a minimum on the next read. The query handler checks the projection's 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.
  • Read from the write store on a narrow path. The confirmation page reads the single order from the authoritative store. Keep this path small and rate-limited: if every read falls back to the write store during an incident, you have rebuilt the contention CQRS was meant to remove.
Version-aware query with a wait budget (illustrative pseudocode)
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.

Projection Rebuilds and Schema Evolution

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.

Blue/green projection rebuildVersion 1 keeps serving reads the whole time. At minute 5 the rebuild records stream position P and starts backfilling version 2 from the write store as of P, while newer events accumulate in the stream. Once the backfill finishes at minute 55, version 2 drains the accumulated events until minute 65 and then follows the live stream. Counts and sampled rows are validated until minute 78, reads switch at minute 80, and version 1 is kept for rollback until minute 110. Durations are illustrative.020406080100120minutes (illustrative)v1 serves readsBackfill v2 as of position PLive events pile up in the streamDrain backlog into v2v2 follows the live streamValidate counts + samplesSwitch reads to v2v2 serves, v1 kept for rollback
Blue/green projection rebuild. Version 1 keeps serving reads the whole time. At minute 5 the rebuild records stream position P and starts backfilling version 2 from the write store as of P, while newer events accumulate in the stream. Once the backfill finishes at minute 55, version 2 drains the accumulated events until minute 65 and then follows the live stream. Counts and sampled rows are validated until minute 78, reads switch at minute 80, and version 1 is kept for rollback until minute 110. Durations are illustrative.
  1. Create version 2 beside version 1. Version 1 keeps serving all reads. Record the current stream position P inside the same snapshot used for the backfill (for example, a repeatable-read transaction that reads the outbox high-water mark before scanning).
  2. Backfill. Populate version 2 from the write store as of P, or replay retained events from the start. Rows carry their versions, so the guard from the previous section still applies.
  3. Drain the backlog. Events published after 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.
  4. Validate. Compare row counts per customer or per day, sample rows against the write store, and compare the new projection with the old one for entities that did not change.
  5. Switch reads with a flag or alias, watching error rates and lag. Keep version 1 updating for a while so rollback is a flag flip.

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.

What to monitor

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.

CQRS and Event Sourcing Are Independent

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.

CQRS and event sourcing are independent choicesA two-by-two grid. Columns say what the write side stores: current state or events. Rows say whether reads use a separate read model. All four combinations are valid: a plain CRUD service, event sourcing with queries served from the same process, CQRS over state tables with an outbox-fed projection, and CQRS with event sourcing, which is the common pairing but not a requirement.Write side stores current stateWrite side stores events (event sourcing)No separateread modelSeparate readmodel (CQRS)Plain CRUD serviceRows are updated in place andqueried through the same model.Event sourcing without CQRSAggregates load by replaying theirevents; the same process answersqueries, for example one aggregate.CQRS without event sourcingState tables on the write side plusan outbox feeding a search indexor denormalised table.CQRS with event sourcingEvents are authoritative andprojections built from them servequeries. Common, not required.
CQRS and event sourcing are independent choices. A two-by-two grid. Columns say what the write side stores: current state or events. Rows say whether reads use a separate read model. All four combinations are valid: a plain CRUD service, event sourcing with queries served from the same process, CQRS over state tables with an outbox-fed projection, and CQRS with event sourcing, which is the common pairing but not a requirement.

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.

Scenario: The Stale Order Dashboard

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".

Order o_924: committed, but invisible in the listThe checkout command commits order o_924 and an outbox row, and its response carries the new order. The projector fails to write to the search index because an index node is down, so the customer's order-list query returns nothing. After the node is repaired the projector replays from its checkpoint and the next query shows the order. The client merges the command response into the list so the customer never sees an empty dashboard.CustomerCommand APIQuery APIOrders DBauthoritativeProjectorSearch index1. POST /checkout2. commit o_924 + outbox3. 201 + order o_9244. read outbox row5. upsert o_924: node down6. GET /orders7. orders for customer8. no o_924 yet9. list without o_92410. retry after repair: upsert11. next query12. includes o_924
Order o_924: committed, but invisible in the list. The checkout command commits order o_924 and an outbox row, and its response carries the new order. The projector fails to write to the search index because an index node is down, so the customer's order-list query returns nothing. After the node is repaired the projector replays from its checkpoint and the next query shows the order. The client merges the command response into the list so the customer never sees an empty dashboard.

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.

When CQRS Is Justified, and When It Is Not

Start with the cheapest form that removes the measured problem

Reach for separate models when:

  • Write invariants and read access patterns genuinely demand different shapes, such as a normalised transactional core with full-text search, per-customer documents, or heavy aggregations.
  • Read volume or query complexity makes a shared schema costly, and indexing, caching, or replicas have been tried and measured.
  • Analytics or reporting reads must not contend with transactional writes, and you can accept their staleness.
  • The team can operate a projection pipeline: lag alerts, dead-letter handling, reconciliation, and rebuilds.

Avoid it, or stop at level 1 or 2, when:

  • The service is CRUD at modest scale. A system doing ten writes a second with simple queries would gain nothing measurable and would acquire a second store, a pipeline, and eventual-consistency work in the UI.
  • Users require a strongly consistent read of their own data everywhere. You can patch specific flows, but if that is the rule rather than the exception, the pattern fights the requirement.
  • Product requirements are still moving. Separate models mean changes ripple through events, projectors, and rebuilds; undoing premature CQRS is expensive.

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.

Where to Go Next

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.

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)