CQRS separates the model for updating data (commands) from the model for reading data (queries). The write model is optimized for consistency and business rules; the read model is optimized for query performance. When used appropriately, CQRS simplifies complex domains and enables read scalability. When overused, it adds indirection without benefit — most CRUD apps don’t need it.
When CQRS Makes Sense
Good fit:
- Complex write logic with many invariants (financial, inventory)
- Dramatically different read patterns vs write patterns (simple writes, complex reporting queries)
- High read volume that needs to scale independently of writes
- Event sourcing (CQRS and event sourcing are natural companions)
Poor fit (use a simpler approach):
- CRUD applications with straightforward data
- Small teams where the added complexity slows everyone down
- When consistent reads are required immediately after writes (eventual consistency is genuinely hard)
Command Side Implementation
Build CQRS for an order system.
Write side: validates business rules, emits events.
Read side: materialized views optimized for the dashboard.
// src/commands/types.ts — command definitions
type Command =
| { type: 'PlaceOrder'; customerId: string; items: OrderItem[]; paymentMethodId: string }
| { type: 'CancelOrder'; orderId: string; reason: string }
| { type: 'ApproveOrder'; orderId: string; approvedBy: string };
type CommandResult<T = void> =
| { success: true; data?: T }
| { success: false; error: string; code: string };
// src/commands/handlers/placeOrderHandler.ts
import { db } from '../../lib/db';
import { eventBus } from '../../lib/eventBus';
export async function handlePlaceOrder(cmd: Extract<Command, { type: 'PlaceOrder' }>): Promise<CommandResult<{ orderId: string }>> {
// 1. Load current state needed for validation
const customer = await db('customers').where('id', cmd.customerId).first();
if (!customer) {
return { success: false, error: 'Customer not found', code: 'CUSTOMER_NOT_FOUND' };
}
if (customer.status === 'suspended') {
return { success: false, error: 'Account suspended', code: 'ACCOUNT_SUSPENDED' };
}
// 2. Validate business invariants
if (cmd.items.length === 0) {
return { success: false, error: 'Order must have at least one item', code: 'EMPTY_ORDER' };
}
const totalCents = await calculateOrderTotal(cmd.items);
if (totalCents > 1_000_000_00) { // $1M limit
return { success: false, error: 'Order exceeds maximum value', code: 'ORDER_TOO_LARGE' };
}
// 3. Execute — write to the write model
const orderId = crypto.randomUUID();
await db.transaction(async (trx) => {
await trx('orders').insert({
id: orderId,
customer_id: cmd.customerId,
payment_method_id: cmd.paymentMethodId,
total_cents: totalCents,
status: 'PENDING',
created_at: new Date().toISOString(),
});
await trx('order_items').insert(
cmd.items.map(item => ({
order_id: orderId,
product_id: item.productId,
quantity: item.quantity,
unit_price_cents: item.priceCents,
}))
);
// 4. Emit event for read model to process
await eventBus.publish('order.placed', {
orderId,
customerId: cmd.customerId,
totalCents,
items: cmd.items,
occurredAt: new Date().toISOString(),
});
});
return { success: true, data: { orderId } };
}
Command Bus
// src/commands/commandBus.ts — dispatches commands to handlers
type Handler<T extends Command> = (cmd: T) => Promise<CommandResult<any>>;
class CommandBus {
private handlers = new Map<string, Handler<any>>();
register<T extends Command>(type: T['type'], handler: Handler<T>) {
this.handlers.set(type, handler as Handler<any>);
}
async dispatch<T extends Command>(cmd: T): Promise<CommandResult<any>> {
const handler = this.handlers.get(cmd.type);
if (!handler) {
return { success: false, error: `No handler for ${cmd.type}`, code: 'NO_HANDLER' };
}
return handler(cmd);
}
}
export const commandBus = new CommandBus();
// Register handlers
commandBus.register('PlaceOrder', handlePlaceOrder);
commandBus.register('CancelOrder', handleCancelOrder);
commandBus.register('ApproveOrder', handleApproveOrder);
Read Model (Projections)
// src/projections/orderSummaryProjection.ts
// Read model: denormalized view optimized for dashboard queries
// Database schema for read model
const readModelSchema = `
CREATE TABLE order_summaries (
order_id UUID PRIMARY KEY,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
status TEXT NOT NULL,
item_count INT NOT NULL,
total_cents BIGINT NOT NULL,
formatted_total TEXT NOT NULL, -- Pre-formatted for display
placed_at TIMESTAMPTZ NOT NULL,
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Denormalized for filtering/sorting
is_high_value BOOLEAN GENERATED ALWAYS AS (total_cents > 100000) STORED,
day_placed DATE GENERATED ALWAYS AS (placed_at::date) STORED
);
CREATE INDEX order_summaries_status_placed ON order_summaries(status, placed_at DESC);
CREATE INDEX order_summaries_customer ON order_summaries(customer_email);
`;
// Event handler: update read model when orders change
export async function onOrderPlaced(event: OrderPlacedEvent) {
const customer = await db('customers').where('id', event.customerId).first();
await db('order_summaries').insert({
order_id: event.orderId,
customer_name: customer.name,
customer_email: customer.email,
status: 'PENDING',
item_count: event.items.length,
total_cents: event.totalCents,
formatted_total: formatCurrency(event.totalCents),
placed_at: event.occurredAt,
});
}
export async function onOrderCancelled(event: OrderCancelledEvent) {
await db('order_summaries')
.where('order_id', event.orderId)
.update({ status: 'CANCELLED', last_updated: new Date().toISOString() });
}
// Query handler: reads from optimized read model
export async function getOrderDashboard(filters: DashboardFilters) {
// Fast query — no joins needed, everything denormalized
return db('order_summaries')
.where(builder => {
if (filters.status) builder.where('status', filters.status);
if (filters.isHighValue) builder.where('is_high_value', true);
if (filters.dateFrom) builder.where('placed_at', '>=', filters.dateFrom);
})
.orderBy('placed_at', 'desc')
.limit(filters.pageSize)
.offset(filters.offset);
}
Query Side
// src/queries/queryBus.ts — separate query handlers from command handlers
class QueryBus {
private handlers = new Map<string, (query: any) => Promise<any>>();
register<T, R>(name: string, handler: (query: T) => Promise<R>) {
this.handlers.set(name, handler as any);
}
async execute<T, R>(name: string, query: T): Promise<R> {
const handler = this.handlers.get(name);
if (!handler) throw new Error(`No query handler for ${name}`);
return handler(query);
}
}
export const queryBus = new QueryBus();
queryBus.register('GetOrderDashboard', getOrderDashboard);
queryBus.register('GetCustomerOrders', getCustomerOrders);
// HTTP layer: thin dispatchers to command/query buses
app.post('/orders', async (req, res) => {
const result = await commandBus.dispatch({
type: 'PlaceOrder',
...req.body,
});
res.status(result.success ? 201 : 400).json(result);
});
app.get('/orders/dashboard', async (req, res) => {
const orders = await queryBus.execute('GetOrderDashboard', req.query);
res.json(orders);
});
Handling Eventual Consistency
The dashboard shows stale data because the read model
updates asynchronously. How do we handle this UX?
// Strategy 1: Return the command result and let the UI optimistically update
// Command returns the created entity — UI doesn't wait for read model sync
app.post('/orders', async (req, res) => {
const result = await commandBus.dispatch({ type: 'PlaceOrder', ...req.body });
if (result.success) {
// Return enough data for UI to update optimistically
res.status(201).json({
orderId: result.data.orderId,
status: 'PENDING',
// These are "estimated" until the projection syncs
_estimatedFields: true,
});
}
});
// Strategy 2: Wait for read model sync (for critical flows)
async function waitForProjection(orderId: string, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const exists = await db('order_summaries').where('order_id', orderId).first();
if (exists) return true;
await new Promise(r => setTimeout(r, 100));
}
return false; // Timed out — return response without waiting
}
For event sourcing as the write model backing CQRS, see the event sourcing guide. For the Kafka-based event publication that drives CQRS projections in distributed systems, the Kafka guide covers reliable event delivery. The Claude Skills 360 bundle includes architecture skill sets covering CQRS implementation, command buses, and projection patterns. Start with the free tier to try CQRS scaffolding.