CQRS
Command Query Responsibility Segregation splits the write and read sides of a system so each can be modeled, scaled, and optimized independently — without inheriting Event Sourcing.
Command Query Responsibility Segregation (CQRS) is the deliberate decision to stop using one model for two jobs. Reads and writes have different shapes, different consistency needs, and different scaling curves, so you give them different models. That is the whole idea. Everything else — messaging, projections, eventual consistency — is machinery you add only when a specific pain justifies it.
CQRS is also the most over-applied pattern in this catalog. The term carries a mystique it does not deserve, and teams routinely adopt the heavyweight version to solve problems a read replica would have solved for a tenth of the cost. Before you learn how to do it, spend real attention on when not to. Greg Young, who named the pattern, has spent years telling people it is smaller and more boring than they imagine. He is right.
The problem
A single domain model that serves both commands and queries is pulled in two directions that get more incompatible as the system grows.
The write side wants a normalized, invariant-protecting model. Your Order aggregate enforces that a line item cannot exist without a product, that totals reconcile, that state transitions are legal. This model is shaped by business rules, not by screens. It is small, deeply consistent, and touched one aggregate at a time.
The read side wants exactly the opposite. A dashboard needs "all orders for this customer in the last 30 days with product names, current shipment status, and running loyalty totals." Served from the normalized write schema, that is a multi-table join, computed on every request, competing for locks and buffer pool with the transactions doing the actual work.
Three specific pressures result:
- Contention. Read-heavy analytical queries and write transactions fight over the same rows, indexes, and connection pool. Long-running reads block writes; heavy writes stall reads.
- Object-relational mismatch. Your ORM entity graph is tuned for loading one aggregate to mutate it. Reporting queries want wide, flat, denormalized rows. One schema cannot be excellent at both, so it is mediocre at each.
- Index sprawl. Every new query shape adds indexes to the write tables. Those indexes slow every insert and update, tax the transaction path, and encode read concerns into the write model, coupling the two forever.
CQRS resolves this by refusing the premise. Commands go through the write model. Queries are served from one or more read models purpose-built for how the data is actually consumed. The two are kept in sync — how, and how quickly, is a design choice, not a mandate.
When to use it
Reach for CQRS when the read and write workloads have genuinely diverged and that divergence is causing pain you can point to:
- Asymmetric load. Reads outnumber writes by one or more orders of magnitude, and you want to scale the read side out without touching the write path or its durability guarantees.
- Divergent shapes. The queries users run look nothing like the aggregates you write. You are maintaining an ever-growing set of joins, views, or ORM projections that fight the write schema.
- Independent scaling and technology fit. You want reads served from a store chosen for reading — Elasticsearch for search, a denormalized document store for a feed, a columnar store for analytics — while writes stay in a transactional database.
- Collaborative, task-based domains. Many users acting on shared state, where commands express intent ("ApproveInvoice") rather than CRUD verbs, and where the read side can tolerate being a fraction of a second behind.
- Team topology. The system is large enough that separate teams own the write and read sides, and a hard seam between them reduces coordination cost.
Note that none of these require Event Sourcing, a message broker, or even eventual consistency. The lightest legitimate CQRS is two models in the same transaction. Start there; add asynchrony only when a bullet above forces your hand.
When NOT to use it
This is the section that matters most, because CQRS is adopted far more often than it is warranted. Default to not using it.
Do not use CQRS for simple CRUD domains. If your reads and writes have the same shape — a form that edits the row a table displays — CQRS is pure overhead. You will build two models, a synchronization mechanism, and a consistency story to reproduce what one repository and one table already did correctly. The overwhelming majority of screens in the overwhelming majority of applications are CRUD. Treat them as CRUD.
Do not adopt it to sound sophisticated. CQRS has cachet, and that is precisely why it gets cargo-culted onto systems that do not need it. Splitting the model is a real, permanent cost: more moving parts, more failure modes, more operational surface. Pay it for a reason you can name, not for an architecture-diagram aesthetic.
Do not use it when eventual consistency is unacceptable to the business. Asynchronous CQRS means a read model can be milliseconds to seconds stale. If the user just submitted a command and the screen shows old data, someone must own that experience — via a synchronous read path for that case, client-side echo, or a "processing…" state. In domains where a stale read is a correctness or compliance failure (certain financial balances, inventory that must never oversell, safety-critical status), do not casually introduce lag. You can run CQRS with strong consistency (update both models in one transaction), but then you forgo most of the scaling benefit and should ask whether you need CQRS at all.
Do not use it with small teams or early-stage products. The two-model seam multiplies the code and cognitive load of every feature. A small team moving fast is better served by one model until the pain is concrete and measured. Premature CQRS is a tax on exactly the velocity an early product needs.
Prefer the cheaper alternative first: "CQRS-lite" via read replicas. A huge fraction of the value people want from CQRS — offloading read traffic, isolating heavy queries from the write path — is available from database read replicas plus a few materialized views, with almost none of the architectural cost. Reads hit replicas; writes hit the primary; the database handles synchronization and its own (usually small, bounded) replication lag. No projections to write, no broker to run, no dual-model consistency logic to reason about. Exhaust this option before building bespoke read models. Full CQRS earns its keep only when replicas cannot give you the shape or technology the read side needs — not merely more read capacity.
Architecture
The essential structure is a one-way flow: commands mutate the write model, changes propagate to one or more read models, and queries are served exclusively from those read models. The write side never serves queries; the read side is never written to directly.
flowchart LR
Client[Client] -->|Command| CH[Command Handler]
CH --> WM[(Write Model)]
WM -->|Change / Event| SYNC[Sync Mechanism]
SYNC --> P1[Projection A]
SYNC --> P2[Projection B]
P1 --> RM1[(Read Model: SQL views)]
P2 --> RM2[(Read Model: Search index)]
Client -->|Query| QH[Query Handler]
QH --> RM1
QH --> RM2
The sync mechanism is the crux, and its options form a spectrum:
- Same transaction (strong consistency). The command handler updates the write model and the read model(s) in one database transaction. No lag, no broker, but the read side is coupled to the write transaction and cannot use a different technology or scale independently. This is legitimate CQRS-lite.
- Asynchronous projection (eventual consistency). The write model emits a change — a domain event, a change-data-capture record, an outbox row — and a separate projector updates the read models out of band. This unlocks independent scaling and heterogeneous read stores at the cost of a lag window and the operational weight of the pipeline.
Crucially, "change / event" in the diagram does not mean you are doing Event Sourcing. It can be a plain domain event published from an outbox, or a CDC stream off the write table. Event Sourcing is one possible source of that stream, not a requirement of it. See the closing section.
Code walkthrough
Below is a minimal asynchronous CQRS slice in TypeScript. The point is the seam: the command side and the query side share no model and know nothing about each other's storage.
The write side is an aggregate that protects invariants. It exposes intent-named methods, not setters:
// write-model/order.ts — the aggregate. Small, consistent, invariant-first.
type OrderStatus = 'draft' | 'placed' | 'cancelled';
export class Order {
private constructor(
readonly id: string,
private status: OrderStatus,
private lines: { productId: string; qty: number }[],
) {}
static draft(id: string) {
return new Order(id, 'draft', []);
}
addLine(productId: string, qty: number) {
if (this.status !== 'draft') throw new Error('can only add lines to a draft');
if (qty <= 0) throw new Error('qty must be positive');
this.lines.push({ productId, qty });
}
place(): OrderPlaced {
if (this.lines.length === 0) throw new Error('cannot place an empty order');
this.status = 'placed';
return { type: 'OrderPlaced', orderId: this.id, lines: this.lines, at: new Date() };
}
}
export type OrderPlaced = {
type: 'OrderPlaced';
orderId: string;
lines: { productId: string; qty: number }[];
at: Date;
};
The aggregate returns a domain event from place(). Note it does not persist that event as its source of truth — it is a plain notification of what happened, which the command handler will store and publish. This is the boundary between CQRS and Event Sourcing.
The command handler orchestrates: load, mutate, persist state, and reliably publish the change. The outbox write is in the same transaction as the state write, so the event cannot be lost if the process dies:
// write-model/place-order.handler.ts
export class PlaceOrderHandler {
constructor(private db: Db, private orders: OrderRepo) {}
async handle(cmd: { orderId: string }) {
await this.db.tx(async (tx) => {
const order = await this.orders.load(cmd.orderId, tx);
const event = order.place(); // enforce invariants
await this.orders.save(order, tx); // persist current state
await tx.insertOutbox(event); // same tx: state + event atomic
});
// A separate relay reads the outbox and publishes to the broker.
}
}
On the other side of the seam, a projector consumes those events and maintains a read model shaped for a specific screen. It is denormalized on purpose — product names and a running total are stored inline so queries never join or compute:
// read-model/customer-orders.projection.ts
export class CustomerOrdersProjection {
constructor(private read: ReadDb, private products: ProductLookup) {}
async on(event: OrderPlaced) {
const rows = await Promise.all(
event.lines.map(async (l) => ({
orderId: event.orderId,
productId: l.productId,
productName: await this.products.nameOf(l.productId), // denormalize now
qty: l.qty,
})),
);
// Idempotent upsert: projections must tolerate redelivery (at-least-once).
await this.read.upsertOrderView(event.orderId, rows, event.at);
}
}
Two things earn their comments. Projections run under at-least-once delivery, so the upsert must be idempotent — replaying the same event must not double-count. And denormalization happens at write-to-read time, not query time: the cost of the product-name lookup is paid once by the projector, never by the thousands of queries that follow.
Finally, the query side. It is deliberately dumb — no domain model, no aggregate, just a shaped read:
// read-model/get-customer-orders.query.ts
export class GetCustomerOrders {
constructor(private read: ReadDb) {}
// No joins, no domain logic — the read model is already the answer.
run(customerId: string) {
return this.read.selectOrderView({ customerId });
}
}
The query handler touches none of the write model's types. You could delete the aggregate and the read side would keep serving queries; you could swap the read store from SQL to Elasticsearch and the aggregate would not notice. That independence is the entire payoff.
Performance characteristics
The metrics on this page are indicative, not measured — directional figures from common deployments, not benchmarks of a specific system. Treat them as shape-of-the-curve, and measure your own.
Read scaling is the headline benefit. Because read models are denormalized and read-only, you can replicate and shard them freely and answer queries without runtime joins, so query p99 tends toward single-digit milliseconds and read throughput scales roughly with the number of read replicas — often about an order of magnitude over what the write model alone would sustain. The write path benefits too: stripping read-serving indexes and joins out of the write schema commonly trims command latency by a meaningful fraction, since every insert maintains fewer indexes.
Projection lag is the cost you trade for it. In the asynchronous design, a read model is behind the write model by the time it takes an event to travel through the outbox relay, the broker, and the projector — typically a window of tens to hundreds of milliseconds, but unbounded under consumer backpressure or a stalled projector. This lag is a first-class part of your design, not an implementation detail: budget for it, monitor it (lag per projection is your key SLI), and make it visible in the UI where it matters. A projector that falls behind is the most common CQRS incident, so alert on consumer lag before users notice stale screens.
Write throughput is bounded by the write model and its transaction, plus the small overhead of the outbox insert. The read side, by construction, cannot slow writes down — which is much of the point. To add a new query shape, you build a new projection and replay events into it, with no change to the write path.
Live demo
An interactive CQRS demo — issuing commands against a write model and watching projections update one or more read models in near real time, with the projection-lag window made visible — is coming soon. It will live alongside the other pattern implementations at stop.procrastin.ar and be linked here once published.
Related patterns
Event Sourcing is the pattern most often conflated with CQRS, and separating them is the single most important thing to understand here. CQRS is about two models; Event Sourcing is about how the write model is stored — as an append-only log of events rather than current-state rows. They compose beautifully: an event log is a natural, ordered source for projections. But neither requires the other. You can do CQRS with a perfectly ordinary state-stored write model that publishes events from an outbox (as shown above), and you can event-source a system that never splits its read and write models. Adopt CQRS for the read/write split; adopt Event Sourcing only if you separately need an audit log, temporal queries, or replay. Bundling them by reflex is how CQRS got its undeserved reputation for complexity.
Materialized views are CQRS-lite in database clothing. A materialized view is a read model, maintained by the database instead of by your projector. When your read needs differ from your write schema only in shape (not technology), and the database can refresh the view acceptably, prefer this over hand-built projections. It gives you much of CQRS's read-side benefit with none of the pipeline.
Saga enters when a command must coordinate work across multiple aggregates or services. CQRS handles the read/write split within a boundary; a saga orchestrates a longer, multi-step business process across boundaries. They are complementary: the events your write model emits can drive both read-model projections and saga steps.
Related patterns
- Composes with: Event Sourcing