Skip to content

CQRS Pattern: Separating Reads from Writes

Command Query Responsibility Segregation uses separate models for reads and writes — when that solves a real problem, and when it is needless complexity.

4 min read
CQRS architecture showing separate command and query paths

Most applications use the same model for reading and writing data. A User entity maps to a users table, and the same model serves both the API response and the update logic. This works until your read patterns diverge significantly from your write patterns — when the data shape your UI needs is completely different from how your domain model stores it. CQRS addresses this by using separate models optimized for each concern.

The Core Idea

Commands modify state. Queries read state. In CQRS, they use different models, potentially backed by different data stores.

tstypescript
// ❌ Single model for reads and writes
class OrderService {
  async getOrders(userId: string): Promise<Order[]> {
    // Query returns domain objects with all business logic
    // UI only needs id, total, status — gets entire aggregate
    return this.orderRepo.findByUser(userId);
  }
 
  async createOrder(data: CreateOrderDto): Promise<Order> {
    const order = new Order(data);
    order.validate();
    return this.orderRepo.save(order);
  }
}
 
// ✅ Separate read and write models
// Write side: rich domain model with business rules
class OrderCommandHandler {
  async handle(command: CreateOrderCommand): Promise<string> {
    const order = new Order(command.userId);
 
    for (const item of command.items) {
      order.addItem(item.productId, item.quantity, item.price);
    }
 
    order.validate();
    await this.orderRepo.save(order);
    return order.id;
  }
}
 
// Read side: flat, optimized projections for queries
class OrderQueryHandler {
  async getOrderSummaries(userId: string): Promise<OrderSummary[]> {
    // Direct database query returning exactly what the UI needs
    return this.db.query(`
      SELECT o.id, o.status, o.total_amount, o.created_at,
             COUNT(oi.id) as item_count
      FROM orders o
      LEFT JOIN order_items oi ON oi.order_id = o.id
      WHERE o.user_id = $1
      GROUP BY o.id
      ORDER BY o.created_at DESC
    `, [userId]);
  }
}

When CQRS Helps

CQRS adds architectural complexity. It's justified when your read and write patterns are fundamentally different.

tstypescript
// Scenario: Dashboard that aggregates data from multiple entities
// Without CQRS, the dashboard query joins 5 tables and computes aggregates on every request
 
// ❌ Complex query on every read
async function getDashboard(userId: string) {
  const orders = await orderRepo.findByUser(userId);
  const payments = await paymentRepo.findByUser(userId);
  const returns = await returnRepo.findByUser(userId);
 
  // Expensive aggregation on every request
  return {
    totalOrders: orders.length,
    totalSpent: orders.reduce((sum, o) => sum + o.total, 0),
    pendingPayments: payments.filter(p => p.status === "pending").length,
    returnRate: returns.length / orders.length,
    recentActivity: mergeAndSort(orders, payments, returns).slice(0, 10),
  };
}
 
// ✅ Pre-computed read model updated when writes happen
interface UserDashboardReadModel {
  userId: string;
  totalOrders: number;
  totalSpent: number;
  pendingPayments: number;
  returnRate: number;
  recentActivity: ActivityEntry[];
  lastUpdated: string;
}
 
// Read model is updated asynchronously when events occur
async function handleOrderCreated(event: OrderCreatedEvent) {
  await db.query(`
    UPDATE user_dashboard
    SET total_orders = total_orders + 1,
        total_spent = total_spent + $2,
        last_updated = NOW()
    WHERE user_id = $1
  `, [event.data.userId, event.data.totalAmount]);
}
 
// Dashboard query is now a single table lookup
async function getDashboard(userId: string): Promise<UserDashboardReadModel> {
  const result = await db.query(
    "SELECT * FROM user_dashboard WHERE user_id = $1",
    [userId]
  );
  return result.rows[0];
}

Projections: Building Read Models

Projections transform write-side events into read-optimized data structures.

tstypescript
class OrderSummaryProjection {
  async handle(event: DomainEvent): Promise<void> {
    switch (event.type) {
      case "order.created":
        await this.onOrderCreated(event as OrderCreatedEvent);
        break;
      case "order.item_added":
        await this.onItemAdded(event as ItemAddedEvent);
        break;
      case "order.submitted":
        await this.onOrderSubmitted(event as OrderSubmittedEvent);
        break;
      case "order.cancelled":
        await this.onOrderCancelled(event as OrderCancelledEvent);
        break;
    }
  }
 
  private async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    await this.db.query(`
      INSERT INTO order_summaries (id, user_id, status, item_count, total_amount, created_at)
      VALUES ($1, $2, 'draft', 0, 0, $3)
    `, [event.aggregateId, event.data.userId, event.occurredAt]);
  }
 
  private async onItemAdded(event: ItemAddedEvent): Promise<void> {
    await this.db.query(`
      UPDATE order_summaries
      SET item_count = item_count + 1,
          total_amount = total_amount + $2
      WHERE id = $1
    `, [event.aggregateId, event.data.price * event.data.quantity]);
  }
 
  private async onOrderSubmitted(event: OrderSubmittedEvent): Promise<void> {
    await this.db.query(`
      UPDATE order_summaries SET status = 'submitted' WHERE id = $1
    `, [event.aggregateId]);
  }
 
  private async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
    await this.db.query(`
      UPDATE order_summaries SET status = 'cancelled' WHERE id = $1
    `, [event.aggregateId]);
  }
}

Eventual Consistency Between Models

The read model updates asynchronously after writes. This means queries might return slightly stale data.

tstypescript
// ❌ Ignoring the consistency gap
app.post("/orders", async (req, res) => {
  await commandBus.dispatch(new CreateOrderCommand(req.body));
  // Immediately redirecting to a page that reads the new order
  // But the read model might not have the new order yet!
  res.redirect(`/orders`);
});
 
// ✅ Handling the consistency gap explicitly
app.post("/orders", async (req, res) => {
  const orderId = await commandBus.dispatch(new CreateOrderCommand(req.body));
 
  // Option 1: Return the command result directly (not from read model)
  res.status(202).json({ orderId, status: "processing" });
});
 
// Client-side: poll or use websocket for read model updates
async function waitForReadModel(orderId: string, maxAttempts = 10): Promise<OrderSummary> {
  for (let i = 0; i < maxAttempts; i++) {
    const summary = await fetch(`/api/orders/${orderId}/summary`);
    if (summary.ok) return summary.json();
    await new Promise(resolve => setTimeout(resolve, 200 * Math.pow(2, i)));
  }
  throw new Error("Read model not yet updated");
}

Rebuilding Read Models

One advantage of CQRS: read models can be rebuilt from the event history. If you add a new dashboard view, rebuild the projection from historical events.

tstypescript
async function rebuildProjection(
  projection: OrderSummaryProjection,
  eventStore: EventStore
): Promise<void> {
  // Clear existing read model
  await projection.reset();
 
  // Replay all events in order
  let lastPosition = 0;
  const batchSize = 1000;
 
  while (true) {
    const events = await eventStore.getEvents({
      afterPosition: lastPosition,
      limit: batchSize,
    });
 
    if (events.length === 0) break;
 
    for (const event of events) {
      await projection.handle(event);
      lastPosition = event.position;
    }
 
    console.log(`Rebuilt up to position ${lastPosition}`);
  }
 
  console.log("Projection rebuild complete");
}

Key Takeaways

  1. CQRS separates read and write models — each side is optimized for its specific access patterns
  2. Pre-computed read models eliminate expensive joins — dashboard queries become simple lookups
  3. Projections transform events into read-optimized structures — they're the bridge between write and read sides
  4. Accept eventual consistency — read models update asynchronously, design your UI accordingly
  5. Read models can be rebuilt — replay events to create new views or fix projection bugs
  6. CQRS is not always necessary — if your reads and writes use the same shape, a single model is simpler
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX