Command-query split
One model optimized for writes and another for reads — CQS principle scaled to architecture. Commands express intent and may be rejected; queries have no side effects. The split begins with two object models, not two databases.
The B2B billing platform’s order domain had a single Order model — a normalized, carefully guarded aggregate. It enforced three invariants: a submitted order’s line-item total must not exceed the customer’s credit limit; an order cannot be modified once approved; and payment terms must match the customer’s contract tier. The team was proud of the model. Every write went through the aggregate root. Invariants held. Then the product team asked for a new orders dashboard: the customer-facing screen that shows every order a company has placed, with status, total, assigned account manager name, the last payment event, and whether the order is overdue. The query touched seven tables. It joined orders, line_items, customers, contracts, account_managers, payments, and billing_events. The ORM loaded the full aggregate graph for every order on screen, then discarded 80% of the data after rendering the view. Response time on the dashboard was 3.4 seconds. The team tried adding indexes, denormalizing a few columns, and caching the response. Every fix made the write model slightly worse — the denormalized columns had to be kept in sync; the cache invalidation logic leaked into the command handlers. The real problem was structural: the same model was being asked to serve two incompatible purposes. For writes, the model needed to be normalized, invariant-guarding, and aggregate-shaped. For reads, it needed to be denormalized, view-shaped, and joined across many entities. No single model can do both well. That tension — one model, two irreconcilable purposes — is what CQRS resolves.
CQS: the principle behind the pattern
CQRS builds on a simpler idea from Bertrand Meyer: Command-Query Separation (CQS). The principle says a method should either change state (a command) or return a value (a query), but never both. A method that returns a value and also has a side effect violates CQS — callers cannot safely call it multiple times, and the return value can no longer be trusted to be pure.
Applied at the object level:
order.submit()— command, changes state, returns nothing (or a domain event)order.getTotal()— query, reads state, returns a value, changes nothing
The value of CQS at the object level is clarity: you can call a query method without fear of side effects. You know a command will not return data that depends on the caller reading it.
CQRS takes the same separation and scales it to the architectural level: instead of one object with command methods and query methods, you have two distinct models — one for writes, one for reads.
Why one model cannot serve both purposes
A write model is shaped by invariants. The Order aggregate is normalized because invariants must be checked over a consistent, minimal set of data. If you denormalize — storing the account manager’s name on the order row — then every name change in account_managers must propagate to every order. The aggregate now has to protect two invariants: the business invariants about orders, and a referential integrity invariant about denormalized data. These invariants fight each other.
A read model is shaped by views. The dashboard query needs account manager name, last payment event, and overdue status in a single fast fetch. No invariant guards these fields — they just need to be correct and fast. The right structure is a pre-joined, denormalized row that the query can return directly without loading the full aggregate graph.
The structural incompatibility is real: normalization serves writes; denormalization serves reads. When forced to coexist in one model, each degrades the other.
▸Why this works
This is not primarily a performance optimization. It is a modeling observation. A normalized aggregate is the right shape for enforcing invariants transactionally. A denormalized view is the right shape for answering questions efficiently. Trying to make one structure serve both roles means it is the right shape for neither. CQRS is not “add a read replica” — it is a modeling decision that says the write concern and the read concern have different structural requirements and should be modeled separately.
Commands: intent, not CRUD
A command in CQRS is not a record update. It is an expression of intent from the outside world toward the domain. SubmitOrder is a command. ApproveOrderForShipping is a command. RejectLineItemNegotiation is a command. These names come from the domain language (see unit 06 on DDD and ubiquitous language).
Commands may be rejected. When a SubmitOrder command arrives and the order total exceeds the customer’s credit limit, the write model rejects it. The command did not silently fail — it was evaluated against an invariant and explicitly refused. The command handler returns a domain event on success or a domain error on rejection.
This is different from a generic PATCH /orders/:id. A PATCH says nothing about intent. A command says exactly what the actor intends to do and why the domain might refuse it.
Commands also make the write model’s responsibility clearer. The write model handles commands. It does not answer queries. Its job is to receive an expression of intent, check invariants, mutate state if the invariants pass, and emit events. That is the entire job.
Queries: no side effects, view-shaped
A query in CQRS is a question. GetOrderDashboard(customerId), GetOrderSummaryForApproval(orderId), ListOverdueOrdersForAccountManager(managerId) are queries. They return data. They do not change state. They may be called multiple times safely.
Because queries have no side effects, they do not need to go through the write model’s invariant machinery. They read from the read model directly — a pre-built, view-shaped data structure optimized for the specific question being asked.
Different queries may have different read models. The dashboard query and the approval summary query serve different views with different data needs. CQRS allows each query to be backed by the most efficient read model for its specific shape.
▸lesson.inset.note
Separate models do not mean separate databases — at least not initially. In the simplest CQRS implementation, the write model and the read model are two different in-process object graphs reading from the same database. The write model reads normalized rows and enforces invariants; the read model executes a different, view-optimized query against the same database, perhaps using a database view or a query that returns a flat DTO. Separate databases (a write store and a read store) are an optional infrastructure step that becomes relevant when the scaling or latency requirements justify it. Many teams apply CQRS at the model level and never reach the point where separate stores are needed.
A developer says: 'We implemented CQRS by adding a read replica database. Commands go to the primary, queries go to the replica.' Has CQRS been correctly applied?
In the CQS principle, a method that returns a value AND changes state violates CQS. Why is this a structural problem and not just a style preference?
The team applies CQRS to the Order domain. A junior engineer proposes: 'The SubmitOrder command handler should return the full Order DTO so the UI can refresh the screen immediately.' What is the CQRS-aligned response?
- 01What is the structural incompatibility that makes one model serve both reads and writes poorly?
- 02What is the difference between a command and a query in CQRS, and why can commands be rejected?
- 03Does CQRS require separate databases for the write and read sides?
The opening story’s 3.4-second dashboard was not a missing index problem. It was a modeling mismatch: one aggregate shaped to guard invariants was being used to serve a view that needed denormalized data from seven tables. The structural tension between normalization (writes need it) and denormalization (reads need it) is what CQRS names and resolves.
CQS — Meyer’s principle — separates command and query methods on the same object. CQRS scales that separation to the architectural level: two distinct models, one per concern. The write model is normalized, invariant-guarding, and aggregate-shaped. The read model is denormalized, view-shaped, and query-optimized. Commands express intent and may be rejected. Queries answer questions and have no side effects.
The split is a modeling decision, not an infrastructure deployment. Separate databases are optional. A team can implement CQRS with two object graphs reading the same database and gain most of the structural benefit.
The next lesson examines the read side in depth: how denormalized read models are built by projecting from the write side, the synchronization problem this creates, and how to bound the staleness.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.