Claude Code for CQRS: Command Query Responsibility Segregation Patterns — Claude Skills 360 Blog
Blog / Architecture / Claude Code for CQRS: Command Query Responsibility Segregation Patterns
Architecture

Claude Code for CQRS: Command Query Responsibility Segregation Patterns

Published: September 30, 2026
Read time: 8 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free