What is CQRS?
Command Query Responsibility Segregation (CQRS) is a pattern that separates read and write operations into different models. Instead of using the same data model to query and update a database, you use separate models for reads (queries) and writes (commands). This separation allows each model to be optimized independently for its specific purpose.
Why CQRS?
- Different optimization needs: Reads often need denormalized views for speed, writes need normalized data for consistency
- Independent scaling: Read-heavy systems can scale the query side independently of the write side
- Complex domains: The write model can be a rich domain model while the read model is a simple DTO projection
- Performance: Read models can be pre-computed materialized views — no expensive joins at query time
CQRS Implementation
// Command side - rich domain model
interface Command {
type: string;
timestamp: Date;
userId: string;
}
interface PlaceOrderCommand extends Command {
type: "PlaceOrder";
customerId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
shippingAddress: Address;
}
interface CancelOrderCommand extends Command {
type: "CancelOrder";
orderId: string;
reason: string;
}
// Command handler
class PlaceOrderHandler {
constructor(
private readonly orderRepo: OrderWriteRepository,
private readonly eventBus: EventBus
) {}
async handle(command: PlaceOrderCommand): Promise {
// Use rich domain model for validation and business rules
const order = Order.create(
command.customerId,
command.shippingAddress
);
for (const item of command.items) {
order.addItem(item.productId, item.quantity, Money.of(item.price, "USD"));
}
order.place();
await this.orderRepo.save(order);
// Publish domain events for the read side to consume
for (const event of order.domainEvents) {
await this.eventBus.publish(event);
}
return order.id;
}
}
// Command bus for routing
class CommandBus {
private handlers = new Map();
register(commandType: string, handler: CommandHandler): void {
this.handlers.set(commandType, handler);
}
async dispatch(command: Command): Promise {
const handler = this.handlers.get(command.type);
if (!handler) throw new Error(`No handler for ${command.type}`);
return handler.handle(command);
}
}
Query Side - Read Models
// Query side - optimized read models (DTOs)
interface OrderSummaryView {
orderId: string;
customerName: string;
status: string;
itemCount: number;
totalAmount: number;
placedAt: string;
}
interface OrderDetailView {
orderId: string;
customerName: string;
customerEmail: string;
status: string;
items: Array<{
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}>;
shippingAddress: Address;
totalAmount: number;
placedAt: string;
updatedAt: string;
}
// Query handlers - simple data retrieval, no business logic
class OrderQueryService {
constructor(private readonly readDb: ReadDatabase) {}
async getOrderSummaries(customerId: string): Promise {
return this.readDb.query(
"SELECT * FROM order_summaries WHERE customer_id = $1 ORDER BY placed_at DESC",
[customerId]
);
}
async getOrderDetail(orderId: string): Promise {
return this.readDb.queryOne(
"SELECT * FROM order_details WHERE order_id = $1",
[orderId]
);
}
async searchOrders(criteria: OrderSearchCriteria): Promise {
// Read model optimized for search — no joins needed
return this.readDb.query(
"SELECT * FROM order_summaries WHERE status = $1 AND placed_at > $2",
[criteria.status, criteria.since]
);
}
}
// Read model projection - updates read DB when events occur
class OrderProjection {
constructor(private readonly readDb: ReadDatabase) {}
async onOrderPlaced(event: OrderPlacedEvent): Promise {
await this.readDb.execute(
`INSERT INTO order_summaries (order_id, customer_name, status, item_count, total_amount, placed_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[event.orderId, event.customerName, "placed", event.itemCount, event.totalAmount, event.placedAt]
);
// Also insert detail view
await this.readDb.execute(
`INSERT INTO order_details (order_id, customer_name, customer_email, status, items, shipping_address, total_amount, placed_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[event.orderId, event.customerName, event.customerEmail, "placed",
JSON.stringify(event.items), JSON.stringify(event.shippingAddress),
event.totalAmount, event.placedAt, event.placedAt]
);
}
async onOrderCancelled(event: OrderCancelledEvent): Promise {
await this.readDb.execute(
"UPDATE order_summaries SET status = $1 WHERE order_id = $2",
["cancelled", event.orderId]
);
await this.readDb.execute(
"UPDATE order_details SET status = $1, updated_at = $2 WHERE order_id = $3",
["cancelled", event.occurredAt, event.orderId]
);
}
}
Wiring It Together
// API layer - separate endpoints for commands and queries
import express from "express";
const app = express();
// Command endpoint - POST (side effects)
app.post("/api/orders", async (req, res) => {
const command: PlaceOrderCommand = {
type: "PlaceOrder",
userId: req.user.id,
timestamp: new Date(),
customerId: req.user.customerId,
items: req.body.items,
shippingAddress: req.body.shippingAddress,
};
const orderId = await commandBus.dispatch(command);
res.status(201).json({ orderId });
});
app.post("/api/orders/:id/cancel", async (req, res) => {
const command: CancelOrderCommand = {
type: "CancelOrder",
userId: req.user.id,
timestamp: new Date(),
orderId: req.params.id,
reason: req.body.reason,
};
await commandBus.dispatch(command);
res.status(200).json({ message: "Order cancelled" });
});
// Query endpoints - GET (no side effects)
app.get("/api/orders", async (req, res) => {
const orders = await orderQueryService.getOrderSummaries(req.user.customerId);
res.json(orders);
});
app.get("/api/orders/:id", async (req, res) => {
const order = await orderQueryService.getOrderDetail(req.params.id);
if (!order) return res.status(404).json({ error: "Not found" });
res.json(order);
});
CQRS Considerations
- Eventual consistency: The read model may lag behind the write model — design the UI to handle this
- Complexity: CQRS adds significant architectural complexity — only use when the read/write asymmetry justifies it
- Not all-or-nothing: Apply CQRS to specific bounded contexts, not the entire system
- Without Event Sourcing: CQRS does not require Event Sourcing — you can use it with a regular database