CQRS Pattern: What Happens When You Separate Reads and Writes
1. Prologue — "Why is the list API so damn slow?"
As a service grows, you'll inevitably hit this wall.
The order list API keeps getting slower. You've added indexes, optimized queries — still slow. The culprit? The Order domain object carries a mountain of business logic, but you're using that same model for reads too.
- Creating an order needs: inventory check, payment validation, coupon application, notification dispatch — dozens of rules.
- Listing orders needs:
id, status, created_at, total_price. Four columns.
Using the same Order model for both causes unnecessary joins, ORM eager-loading relationships you never asked for, and a DB that's crying under the load.
That's the exact problem CQRS is designed to solve.
2. What Is CQRS?
CQRS stands for Command Query Responsibility Segregation.
Greg Young formalized it around 2010. Martin Fowler covers it in depth on his blog. The core idea is dead simple:
Operations that change system state (Commands) and operations that read state (Queries) should use separate models.
That's it. Everything else is just how far you want to push that idea.
Command vs Query
| Aspect | Command | Query |
|---|
| Purpose | Change state | Read state |
| Return value | None (or just an ID) | Data |
| Examples | PlaceOrder, CancelOrder | GetOrderList, GetOrderDetail |
| Side effects | Yes (DB write, event publish) | None (side-effect free) |
This traces back to Bertrand Meyer's CQS (Command Query Separation) principle from 1988. CQRS lifts that principle from the object level up to the system/architecture level.
3. The Single Model Problem
In traditional CRUD architectures, one domain model does everything.
// Traditional single model — trying to be everything
class Order {
id: string;
customerId: string;
items: OrderItem[];
status: OrderStatus;
shippingAddress: Address;
paymentInfo: PaymentInfo;
discountCoupons: Coupon[];
// Business logic
place(): void { /* inventory check, payment, notifications... */ }
cancel(): void { /* refund, inventory restore... */ }
ship(): void { /* update shipping status... */ }
// Read methods crammed in too
getSummary(): OrderSummary { /* ... */ }
getDetailView(): OrderDetailView { /* ... */ }
}
The problems:
- Write optimization vs read optimization conflict: Writes benefit from normalized tables. Reads benefit from denormalized views. Trying to do both means doing neither well.
- Caching is hard: Caching a mutable model creates consistency nightmares.
- Different scaling needs: Most systems have 10x–100x more read traffic than writes. But with a shared model, they compete on the same DB instance.
- Complex query requirements: Dashboards, reports, analytics — these need complex multi-table joins. Cramming that into a domain model makes everything messy.
4. Three Levels of CQRS Implementation
CQRS isn't all-or-nothing. There's a spectrum.
Level 1: Code-Level Separation (Simplest)
Same DB, but Command and Query handling code is physically separated.
// commands/place-order.command.ts
export interface PlaceOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddressId: string;
couponCode?: string;
}
// command-handlers/place-order.handler.ts
export class PlaceOrderHandler {
constructor(
private readonly orderRepository: OrderRepository,
private readonly inventoryService: InventoryService,
private readonly paymentService: PaymentService,
) {}
async handle(command: PlaceOrderCommand): Promise<string> {
await this.inventoryService.reserve(command.items);
const order = Order.create(command);
await this.paymentService.authorize(order);
await this.orderRepository.save(order);
return order.id;
}
}
// queries/get-order-list.query.ts
export interface GetOrderListQuery {
customerId: string;
page: number;
pageSize: number;
status?: OrderStatus;
}
export interface OrderListItem {
id: string;
status: OrderStatus;
totalPrice: number;
createdAt: Date;
itemCount: number;
}
// query-handlers/get-order-list.handler.ts
export class GetOrderListHandler {
constructor(private readonly db: DatabaseConnection) {}
async handle(query: GetOrderListQuery): Promise<OrderListItem[]> {
// Optimized for reads — no domain object instantiation
return this.db.query(
`SELECT
o.id,
o.status,
o.total_price,
o.created_at,
COUNT(oi.id) as item_count
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.customer_id = $1
AND ($3::text IS NULL OR o.status = $3)
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT $2 OFFSET ${(query.page - 1) * query.pageSize}`,
[query.customerId, query.pageSize, query.status ?? null]
);
}
}
Even at this level, the codebase becomes dramatically clearer. "This code changes things" vs "this code reads things" is structurally enforced.
Level 2: Separate Read Models
Maintain a dedicated read model (also called View Model or Projection). Update it whenever write events occur.
// Read model — denormalized for fast queries
// CREATE TABLE order_read_models (
// id UUID PRIMARY KEY,
// customer_id UUID,
// customer_name TEXT, -- denormalized: no customers join needed
// status TEXT,
// total_price DECIMAL,
// item_count INTEGER,
// item_names TEXT[], -- denormalized: pre-aggregated data
// created_at TIMESTAMP
// );
export class OrderReadModelProjector {
constructor(private readonly db: DatabaseConnection) {}
async onOrderPlaced(event: OrderPlacedEvent): Promise<void> {
await this.db.query(
`INSERT INTO order_read_models
(id, customer_id, customer_name, status, total_price, item_count, item_names, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
event.orderId,
event.customerId,
event.customerName,
'PLACED',
event.totalPrice,
event.items.length,
event.items.map(i => i.name),
event.occurredAt,
]
);
}
async onOrderShipped(event: OrderShippedEvent): Promise<void> {
await this.db.query(
`UPDATE order_read_models SET status = 'SHIPPED' WHERE id = $1`,
[event.orderId]
);
}
}
Now GetOrderListHandler just hits order_read_models. No joins, no ORM overhead, blazing fast.
Level 3: Separate Data Stores (Full CQRS)
Physically separate the Command store (Write DB) from the Query store (Read DB).
[Client]
│
├── Command ──▶ [Write API] ──▶ [Write DB (PostgreSQL)]
│ │
│ └── publish event ──▶ [Message Queue]
│ │
│ ▼
│ [Read Model Updater]
│ │
│ ▼
└── Query ───▶ [Read API] ──▶ [Read DB (Redis/Elasticsearch/Read Replica)]
- Write DB: Normalized, transactions, complex business logic
- Read DB: Denormalized, blazing fast queries, search-optimized (Elasticsearch), cached (Redis)
At this level, reads and writes scale completely independently. Traffic explosion on reads? Scale up Read DB instances only.
5. CQRS and Event Sourcing
These two patterns are frequently mentioned together. They're independent patterns that happen to pair beautifully.
Event Sourcing means storing the history of state changes (events) rather than the current state itself.
// Without event sourcing (current state stored)
// orders table: { id, status: 'SHIPPED', total_price: 50000, ... }
// With event sourcing (event history stored)
// order_events table:
// { order_id, type: 'OrderPlaced', payload: {...}, timestamp: t1 }
// { order_id, type: 'PaymentMade', payload: {...}, timestamp: t2 }
// { order_id, type: 'OrderShipped', payload: {...}, timestamp: t3 }
// Current state is computed by replaying events
class OrderAggregate {
private state: OrderState = { status: 'NEW', totalPrice: 0 };
apply(event: OrderEvent): void {
switch (event.type) {
case 'OrderPlaced':
this.state.status = 'PLACED';
this.state.totalPrice = event.payload.totalPrice;
break;
case 'PaymentMade':
this.state.status = 'PAID';
break;
case 'OrderShipped':
this.state.status = 'SHIPPED';
break;
}
}
static rehydrate(events: OrderEvent[]): OrderAggregate {
const order = new OrderAggregate();
events.forEach(e => order.apply(e));
return order;
}
}
With event sourcing, CQRS becomes natural: attach a Projector to update read models whenever events are published. You also get free audit logs and the ability to reconstruct state at any point in time.
6. Practical Implementation: NestJS + CQRS Module
NestJS ships @nestjs/cqrs which makes this clean to implement:
// place-order.command.ts
import { ICommand } from '@nestjs/cqrs';
export class PlaceOrderCommand implements ICommand {
constructor(
public readonly customerId: string,
public readonly items: OrderItemDto[],
public readonly shippingAddressId: string,
) {}
}
// place-order.handler.ts
@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: PlaceOrderCommand): Promise<string> {
const order = Order.create(command.customerId, command.items);
await this.orderRepo.save(order);
this.eventBus.publish(new OrderPlacedEvent(order.id, order.customerId));
return order.id;
}
}
// get-orders.query.ts
export class GetOrdersQuery implements IQuery {
constructor(
public readonly customerId: string,
public readonly page: number,
) {}
}
// get-orders.handler.ts
@QueryHandler(GetOrdersQuery)
export class GetOrdersHandler implements IQueryHandler<GetOrdersQuery> {
constructor(private readonly readDb: ReadDatabaseService) {}
async execute(query: GetOrdersQuery): Promise<OrderListItem[]> {
return this.readDb.getOrdersByCustomer(query.customerId, query.page);
}
}
// orders.controller.ts — clean as a whistle
@Controller('orders')
export class OrdersController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async placeOrder(@Body() dto: PlaceOrderDto, @CurrentUser() user: User) {
const orderId = await this.commandBus.execute(
new PlaceOrderCommand(user.id, dto.items, dto.shippingAddressId)
);
return { orderId };
}
@Get()
async getOrders(@Query() query: GetOrdersDto, @CurrentUser() user: User) {
return this.queryBus.execute(
new GetOrdersQuery(user.id, query.page ?? 1)
);
}
}
The controller has zero business logic. It just shapes Commands and Queries and puts them on the bus.
7. When CQRS Is Overkill
CQRS is not a silver bullet. In many cases it adds complexity without payoff.
| Situation | CQRS Fit |
|---|
| Simple CRUD app | Poor — plain Repository pattern is enough |
| Small team, rapid prototyping | Poor — upfront overhead is real |
| Read/write traffic roughly equal | Mediocre — limited gain from separation |
| Complex business domain (DDD) | Good — clean separation of domain vs query logic |
| Read traffic vastly outweighs writes | Excellent — independent read scaling |
| Event-driven microservices | Excellent — loose coupling between services |
Critical caveat: Eventual Consistency. After a write, there's a delay before the read model updates. A user who places an order and immediately refreshes the list might not see their order yet. Always validate this is acceptable for your use case before committing to full CQRS.
8. Conclusion
CQRS doesn't eliminate complexity — it places complexity where it belongs.
- Write model: focus on business rules and consistency
- Read model: focus on performance and user experience
The pragmatic path is to start at Level 1 (code separation only) and increase the separation level incrementally as the need arises. Going full CQRS from day one usually means the team burns out before it delivers value.
The deciding question: "Is there enough of a complexity gap between reads and writes in this domain?" If the answer is yes, CQRS pays dividends. If not, a clean repository pattern will serve you just fine.