TechLead
Lesson 7 of 27
5 min read
Software Architecture

CQRS Pattern

Understand Command Query Responsibility Segregation for separating read and write models in complex systems

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

Continue Learning