CQRS (Command Query Responsibility Segregation) is a pattern that separates read and write operations into different models. While often paired with event sourcing, CQRS is a valuable pattern on its own. Let’s explore what it is, why it helps, and how to implement it effectively.

Table of contents

The Problem with Unified Models

In traditional architectures, we use a single model for both reading and writing data. This seems simple, but it creates friction:

  • Read optimization conflicts with write optimization: Queries often need denormalized data with joins, while writes need normalized data for consistency
  • Different scalability requirements: Reads often outnumber writes 100:1, but they share resources
  • Complex domain models: Business logic for writes gets tangled with query requirements

Enter CQRS

CQRS proposes a simple but powerful idea: use different models for reading and writing.

┌─────────────┐     ┌─────────────────┐
│   Command   │────▶│  Write Model    │
│   (Write)   │     │  (Aggregates)   │
└─────────────┘     └────────┬────────┘


                    ┌─────────────────┐
                    │     Events      │
                    └────────┬────────┘


┌─────────────┐     ┌─────────────────┐
│   Query     │◀────│   Read Model    │
│   (Read)    │     │  (Projections)  │
└─────────────┘     └─────────────────┘

The Write Side (Commands)

The write side handles all state changes through commands. Commands represent user intentions:

  • CreateOrder
  • ShipOrder
  • CancelOrder

Commands are processed by aggregates—domain objects that enforce business rules and emit events when state changes.

The Read Side (Queries)

The read side handles all data retrieval through queries. Queries are served by projections—pre-computed views optimized for specific use cases:

  • OrderSummaryProjection: Lightweight view for order lists
  • OrderDetailProjection: Full order details with line items
  • CustomerOrderHistoryProjection: All orders for a customer

Benefits of CQRS

1. Independent Scaling

Scale your read and write infrastructure independently. Most applications are read-heavy; CQRS lets you add read replicas without affecting write performance.

2. Optimized Models

Each side can use the most appropriate storage and data structure:

  • Write side: Normalized relational database for consistency
  • Read side: Denormalized documents, search indices, or caches for speed

3. Simplified Domain Logic

Your write model focuses purely on business rules, not query optimization. This leads to cleaner, more maintainable code.

4. Better Performance

Queries hit pre-computed projections instead of computing joins at query time. This can dramatically reduce query latency.

The Trade-off: Eventual Consistency

With CQRS, there’s a lag between when a command is processed and when the read model reflects the change. This is eventual consistency.

For many use cases, this is fine—users don’t notice a 100ms delay. But some scenarios require immediate consistency:

// After creating an order, immediately show it
const orderId = await createOrder(orderData);

// Problem: the projection might not be updated yet
const order = await getOrder(orderId); // Might return null!

Solutions include:

  1. Return data from command: Include the created entity in the command response
  2. Read-your-writes: Query the write model directly after a write
  3. Synchronous projections: Update critical projections in the same transaction

Implementing CQRS

You don’t need special frameworks to implement CQRS. Start simple:

1. Separate Your Interfaces

// Command interface
interface OrderCommands {
  createOrder(data: CreateOrderData): Promise<OrderId>;
  shipOrder(orderId: OrderId): Promise<void>;
}

// Query interface
interface OrderQueries {
  getOrderSummary(orderId: OrderId): Promise<OrderSummary>;
  getCustomerOrders(customerId: CustomerId): Promise<OrderSummary[]>;
}

2. Create Dedicated Read Models

Instead of querying your entities directly, create view-specific projections:

// Projection optimized for the order list page
interface OrderListItem {
  id: string;
  customerName: string;
  totalAmount: number;
  status: string;
  createdAt: Date;
}

3. Keep Projections Updated

When events occur, update your projections:

async function handleOrderCreated(event: OrderCreated) {
  await db.orderListProjection.insert({
    id: event.orderId,
    customerName: event.customerName,
    totalAmount: event.totalAmount,
    status: 'pending',
    createdAt: event.timestamp
  });
}

When to Use CQRS

CQRS adds complexity. Use it when:

  • Read and write patterns are significantly different
  • You need to scale reads and writes independently
  • Your domain logic is complex and benefits from separation
  • You’re already using event sourcing (they pair naturally)

Skip it when:

  • Your application is simple CRUD
  • Read and write patterns are similar
  • The team is unfamiliar with the pattern

Next Steps

In our next post, we’ll explore projections in depth—how to design them, handle failures, and rebuild them when requirements change.


Want to implement CQRS without building the infrastructure yourself? Learn about Stateful Data or join our pilot program.