CQRS (Command Query Responsibility Segregation)
Learn how CQRS separates reads from writes into independent models so each can be optimized, scaled, and evolved without the other paying the cost.
TL;DR
- CQRS separates every system operation into two categories: Commands (intent to change state) and Queries (requests for data), with independent models, schemas, and code paths for each.
- The root driver is asymmetric load: production systems run 10β100Γ more reads than writes, yet a unified model forces both to compete on the same schema, the same indexes, and the same database connections.
- Commands go through validation and domain logic before mutating state. Queries skip all of that β they hit a denormalized read projection that is pre-computed exactly for what the UI needs. No JOINs. No aggregate reconstruction.
- The fundamental tension is consistency vs. performance: the read model is asynchronously updated after write events, meaning reads return a snapshot that may be milliseconds to seconds behind the latest write. This is eventual consistency, by design.
- CQRS adds real complexity β a second data store, an event bus, projection infrastructure, and operational overhead. Only reach for it when read and write workloads genuinely differ in volume, shape, or scaling requirements.
The Problem
It's 11:59 a.m. on a flash sale day. Your e-commerce platform is absorbing 50,000 concurrent product-page views per minute from users refreshing to see whether their target item is in stock. At the same time, your order flow is trying to write at 400 orders/minute.
Those seem like different problems at different scales. But they both touch the same Product aggregate, the same Inventory table, and ultimately the same PostgreSQL instance.
Here's where it breaks: your product-page query does a four-table JOIN across products, inventory, pricing, and promotions to assemble the display view. This query runs 833 times per second at peak. Each one takes 8ms.
Meanwhile, an order write needs to lock the inventory row to decrement stock atomically. The reads hold shared row locks. The inventory write queues behind them.
Orders start failing. Retry storms amplify the contention. By 12:01 p.m. your error rate is 12% and climbing β not because you ran out of database capacity, but because a complex read query and a critical write path are competing on the same row.
This is the pattern I see in almost every read-scaling incident before teams reach for CQRS β the bottleneck isn't hardware, it's data model contention.
The deeper issue: a single model can't be optimal for reading and writing simultaneously. Normalized schemas favor writes (less duplication, fewer update anomalies); denormalized schemas favor reads (fewer JOINs, pre-computed answers). No single table design can optimize for both.
Adding read replicas helps with read throughput, but doesn't help with query complexity β your expensive four-table JOIN still runs on every replica, every request. You need a different shape of data, not more copies of the same shape.
One-Line Definition
CQRS routes all write operations through a Command Model that enforces business rules and domain logic and routes all read operations through a Query Model purpose-built for the exact data shape each UI view needs, so each side can be independently optimized, scaled, and evolved.
CQRS takes its name from Command-Query Separation (CQS) β a method-level principle by Bertrand Meyer stating that every function is either a command (changes state, returns nothing) or a query (returns data, never changes state), never both. CQRS applies that principle at the architectural level: entire code paths, data models, and infrastructure are separated by role. Knowing this lineage matters in interviews because some interviewers use "CQS" when they mean "CQRS" β clarifying the distinction signals depth.
Analogy
Think of a busy hospital's medical records system.
When a surgeon files an operation note, it goes through a structured, validated, legally-binding intake process β specific form fields, mandatory signatures, routing to the patient's chart by code. That's the command side: business rules enforced, domain model applied, state changed on a transaction boundary.
When the billing department pulls a patient summary, they don't read the raw operation notes. They read a billing projection β a pre-assembled view that aggregates diagnosis codes, procedure codes, and dates from dozens of underlying records into a format the billing software can consume directly. No JOINs required at read time. No surgeon filing a note has any idea this projection exists.
Same underlying patient data. Two completely different models. Because the shape of data needed for a surgical note and the shape needed for a billing view are completely different problems.
CQRS applies the same logic to software: separate changing state from reading state. They have different validation requirements, different data shapes, and β at scale β fundamentally different load profiles.
The Full Architecture
The critical design constraint: the command side and query side must never talk to each other synchronously. Commands write to the Write Store, emit a domain event, and are done. Projections listen to events and maintain their own optimized read stores. This one-way async coupling is what gives each side the freedom to evolve and scale independently.
For your first interview: write that two-path diagram in the first 3 minutes, label the event bus, and move on. That one sketch separates CQRS-literate candidates from everyone else who just says "separate reads and writes."
Solution Walkthrough: The Command Side
A command is an expression of intent: PlaceOrder, CancelShipment, UpdateInventoryCount. It carries everything the handler needs to execute the intent and nothing else. It does not return data β it returns success/failure.
// command-types.ts β SKETCH
// Commands are named by intent, not by CRUD verb.
// "UpdateProduct" isn't a command name. "AdjustProductPrice" is.
interface PlaceOrderCommand {
readonly type: 'PlaceOrder';
readonly commandId: string; // idempotency key β critical for retry safety
readonly userId: string;
readonly items: Array<{ productId: string; quantity: number }>;
readonly shippingAddressId: string;
}
interface AdjustInventoryCommand {
readonly type: 'AdjustInventory';
readonly commandId: string;
readonly productId: string;
readonly delta: number; // positive = restock, negative = sale
readonly reason: 'sale' | 'return' | 'manual-adjustment';
}
type OrderCommand = PlaceOrderCommand | AdjustInventoryCommand;
The command handler is the entry point. It validates the command, loads the aggregate from the Write Store, applies the command to the aggregate (which enforces domain rules), persists the result, and publishes a domain event.
// place-order-handler.ts β SKETCH
// Real implementations use a command bus / dispatcher.
// This shows the mechanics without the plumbing.
class PlaceOrderHandler {
constructor(
private readonly orderRepo: OrderRepository,
private readonly inventoryService: InventoryService,
private readonly eventBus: EventBus,
private readonly idempotencyStore: IdempotencyStore,
) {}
async handle(cmd: PlaceOrderCommand): Promise<OrderId> {
// 1. Idempotency guard β a retry of the same commandId is a no-op
const existing = await this.idempotencyStore.get(cmd.commandId);
if (existing) return existing.orderId;
// 2. Validate inputs at the boundary (not inside the aggregate)
if (cmd.items.length === 0) throw new ValidationError('Order must have items');
// 3. Domain logic: check stock inside a transaction
const order = await this.orderRepo.withTransaction(async (tx) => {
// Load + reserve β aggregate enforces business invariants
const reserved = await this.inventoryService.reserveItems(cmd.items, tx);
if (!reserved.success) throw new InsufficientStockError(reserved.unavailable);
const newOrder = Order.create({
userId: cmd.userId,
items: cmd.items,
shippingAddressId: cmd.shippingAddressId,
});
await this.orderRepo.save(newOrder, tx);
await this.idempotencyStore.set(cmd.commandId, { orderId: newOrder.id }, tx);
return newOrder;
});
// 4. Publish event OUTSIDE transaction β projection updates are async
await this.eventBus.publish({
type: 'OrderPlaced',
orderId: order.id,
userId: order.userId,
items: order.items,
placedAt: new Date().toISOString(),
});
return order.id;
}
}
The command side never returns the object it just created. It returns an identifier. The client queries the read model to get the full view. This is not an accident β it's the contract.
Solution Walkthrough: The Query Side
A query expresses the shape of data a UI view needs. GetOrderSummaryForUser, GetProductListingPage, GetRecentActivityFeed. It carries filter parameters and returns a pre-assembled data structure. It contains zero business logic.
// query-types.ts β SKETCH
interface GetOrderSummaryQuery {
readonly type: 'GetOrderSummary';
readonly orderId: string;
readonly requestingUserId: string; // for authorization check only
}
// The query handler reads directly from the read projection.
// No JOIN. No aggregate load. Just a key-value lookup or a
// single flat table SELECT optimized for this exact query shape.
class GetOrderSummaryHandler {
constructor(private readonly readDb: ReadDatabase) {}
async handle(query: GetOrderSummaryQuery): Promise<OrderSummaryView | null> {
// Authorization check ONLY β projections contain no business logic
const view = await this.readDb.queryOne<OrderSummaryView>(
'SELECT * FROM order_summary_projection WHERE order_id = $1',
[query.orderId]
);
if (!view || view.userId !== query.requestingUserId) return null;
return view; // already in the shape the UI needs β no mapping
}
}
That second path β where the client immediately queries their newly-placed order and gets a 404 β is the most common reason teams regret adopting CQRS without planning for it. I'll cover the mitigation in Failure Modes.
Read Model Projections
A projection is a continuously-updated, denormalized view of your data, computed by processing the stream of domain events. Key properties:
| Property | What it means |
|---|---|
| Purpose-built | Each projection stores exactly what one query type needs β no more, no less |
| Denormalized | Data is repeating intentionally: the OrderSummary projection might store the user's full name even though it's in the users table, because it was true at the time of the order |
| Append-friendly | Projections are updated by processing new events β never by reading the current state and merging |
| Rebuildable | If a projection is wrong or corrupt, delete it and replay all events from the beginning β the final state is identical because events are the source of truth |
| Storage-agnostic | Each projection can live in Redis (< 1ms key lookups), Elasticsearch (full-text search), Redshift (analytics), Postgres (complex queries) β choose the tool that best fits the query pattern |
| Idempotent | Processing the same event twice must produce the same output β mandatory because at-least-once delivery guarantees duplicate events will arrive. Use UPSERT keyed on the event's id in every projection handler. |
The insight most candidates miss: you can have as many projections as you have query patterns. A single OrderPlaced event might update OrderSummary, UserOrderHistory, InventoryLevels, RevenueMetrics, and SearchIndex β five independent stores, each optimized for a completely different query shape. The same event fans out to all of them.
Implementation Sketch: The Full Loop
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.