CQRS Pattern

Separate read and write models for optimized query and command paths. Covers CQRS architecture, event sourcing integration, read model projections, eventual consistency trade-offs, and when CQRS is worth the complexity.

CQRS (Command Query Responsibility Segregation) separates the read and write models of a system, allowing each to evolve independently. It matters when data access patterns diverge: when writes are complex and frequent, but reads are numerous, varied, and performance-sensitive. Use CQRS when you need high throughput for updates, low-latency queries, or when query logic grows beyond what a single database schema can support. It becomes essential when your system’s domain logic is rich and your users expect fast, real-time views of data.

Core Principles

CQRS is built on two distinct models: the *command model* (also *write model*) and the *query model* (also *read model*). Commands are operations that change state—CreateUser, UpdateOrder, ProcessPayment. Queries are operations that retrieve data—GetUserDetails, ListOrdersForUser, GetOrderSummary.

Commands are sent to a command handler, which applies domain logic and updates the command model. The command model is typically a normalized, transactional database with strong consistency. The command model emits events as side effects of state changes.

Queries are served from a denormalized, optimized read model, often a separate database or data store. The read model is updated asynchronously by event processors that consume events from the command model’s event stream.

The system’s state is not derived from the command model alone; it is eventually consistent with respect to the read model.

Event-Driven State Updates

Events are the glue between models. A command handler emits one or more events upon successful execution. For example, OrderCreated, OrderShipped, PaymentProcessed.

Events are stored in an event store, a log of all state changes, typically as append-only records. Each event includes:

A typical event might be:

{
  "eventId": "e5a3b1f7-2d6c-4f1a-8e9b-7c6d5e4f3a2b",
  "eventType": "OrderCreated",
  "timestamp": "2025-04-05T10:30:45.123Z",
  "aggregateId": "order-123",
  "data": {
    "customerId": "cust-456",
    "totalAmount": 29.99,
    "items": [
      { "productId": "prod-1", "quantity": 2, "price": 9.99 }
    ]
  }
}

Event processors consume events and update the read model. A processor might be a long-running service or a batch job.

Command Handling and Domain Logic

Commands are sent via an HTTP API, message queue, or RPC. The command handler validates input, applies business rules, and persists state.

Example command request:

POST /commands
Content-Type: application/json

{
  "commandType": "CreateUser",
  "payload": {
    "name": "Alice Smith",
    "email": "[email protected]",
    "preferredLanguage": "en"
  }
}

The command handler returns a response indicating success or failure:

{
  "commandId": "cmd-789",
  "status": "Accepted",
  "timestamp": "2025-04-05T10:30:46.234Z",
  "correlationId": "corr-abc123"
}

The correlationId links the command with its events and eventual query results. It is critical for tracing and debugging.

Read Model Population

The read model is updated via event processors. Each processor listens to a subset of events and updates one or more tables or indexes.

For example, an OrderSummaryProcessor listens to OrderCreated, OrderShipped, and PaymentProcessed events. It maintains a order_summaries table with:

Each event triggers a UPDATE or INSERT into this table. The processor may be implemented as a service using a message queue.

Example event processing loop:

def process_events():
    while True:
        events = message_queue.consume(topic="events", batch_size=100)
        for event in events:
            handle_event(event)

def handle_event(event):
    match event["eventType"]:
        case "OrderCreated":
            insert_order_summary(
                orderId=event["aggregateId"],
                customerId=event["data"]["customerId"],
                totalAmount=event["data"]["totalAmount"],
                status="Created",
                updatedAt=event["timestamp"]
            )
        case "OrderShipped":
            update_order_summary(
                orderId=event["aggregateId"],
                status="Shipped",
                updatedAt=event["timestamp"]
            )
        case "PaymentProcessed":
            update_order_summary(
                orderId=event["aggregateId"],
                status="Paid",
                updatedAt=event["timestamp"]
            )

Query Execution

Queries are served from the read model. A query such as GetOrderSummary might be:

GET /queries/order-summary?orderId=order-123

Response:

{
  "orderId": "order-123",
  "customerId": "cust-456",
  "status": "Shipped",
  "totalAmount": 29.99,
  "shippingAddress": {
    "line1": "123 Main St",
    "city": "Downtown",
    "zip": "90210"
  },
  "updatedAt": "2025-04-05T11:15:23.456Z"
}

The read model may be a PostgreSQL table, a document database like MongoDB, or a search engine like Elasticsearch. Queries are fast, often served from memory or in a cache.

Common Failure Modes

Eventual Consistency

The read model is not updated immediately. There can be a delay of seconds to hours between command execution and query availability. A user creates an order, sees a confirmation, but the order summary takes 30 seconds to appear.

This introduces eventual consistency. The system is correct over time, but not immediately consistent. Clients must expect that a query may return stale data.

Event Processing Failures

Event processors may fail to process events. The most common failure is event duplication. A processor reads an event, processes it, but fails before acknowledging the message. The message queue re-delivers the event, leading to duplicate updates.

To prevent this, event processors use idempotent operations. The order_summaries table must handle duplicate INSERTs and UPDATEs without side effects.

For example, an UPDATE may be:

INSERT INTO order_summaries (orderId, status, updatedAt)
VALUES ('order-123', 'Shipped', '2025-04-05T11:15:23.456Z')
ON CONFLICT (orderId)
DO UPDATE SET status = EXCLUDED.status, updatedAt = EXCLUDED.updatedAt;

Missing or Misordered Events

Events are not guaranteed to arrive in order. If the event stream is partitioned, events for the same aggregate may arrive out of sequence. A processor must handle this.

A PaymentProcessed event may arrive before OrderCreated for the same order. The processor must buffer events and process them in order, or use event versioning.

Event Schema Evolution

Events evolve over time. A UserUpdated event may change from:

{
  "eventType": "UserUpdated",
  "data": {
    "userId": "u1",
    "name": "Alice",
    "email": "[email protected]"
  }
}

to:

{
  "eventType": "UserUpdated",
  "data": {
    "userId": "u1",
    "name": "Alice",
    "email": "[email protected]",
    "lastLogin": "2025-04-05T10:00:00Z",
    "preferences": {
      "theme": "dark",
      "timezone": "UTC"
    }
  }
}

Old processors may fail to parse new event structures. To handle this, use event versioning:

Command-Query Mismatch

A query may expect a field that is not updated in the read model. For example, GetUserDetails queries users table but misses preferences from UserUpdated.v2. The result is incomplete or incorrect data.

The solution is to ensure that all relevant events are processed and that the read model is fully materialized.

Correlation and Tracing

Without correlationId, tracing command execution is difficult. When a query returns stale data, it’s hard to determine:

A request to GetOrderSummary may return data from 30 seconds ago. To diagnose, developers must:

  1. Look up the correlationId in logs.
  2. Trace the command through the system.
  3. Examine the event stream.
  4. Check the event processor logs.

Idempotent Commands

Commands should be idempotent. A client may retry a command that fails. The command handler must handle duplicate commands.

For example, a client sends CreateUser with userId: u1. The server processes it, returns success. But the client gets a timeout. It retries the same command. The server must detect the duplicate and return the same result.

Idempotency is achieved by:

A command handler may return a commandId and mark the command as processed in a command_log table:

CREATE TABLE command_log (
    commandId VARCHAR(36) PRIMARY KEY,
    commandType VARCHAR(100),
    payload JSON,
    status VARCHAR(20),
    createdAt TIMESTAMP,
    processedAt TIMESTAMP
);

Configuration and Deployment

CQRS systems are often deployed as a set of microservices. Each service handles a domain model.

Deployment uses standard tools: docker, kubectl, systemctl, git.

Example docker-compose.yml for a CQRS system:

version: '3.8'

services:
  command-api:
    image: command-api:latest
    ports:
      - "8080:80"
    depends_on:
      - postgres
      - kafka

  event-store:
    image: event-store:latest
    ports:
      - "2113:2113"
    volumes:
      - eventstore-data:/data

  query-api:
    image: query-api:latest
    ports:
      - "8081:80"
    depends_on:
      - postgres

  order-processor:
    image: order-processor:latest
    depends_on:
      - kafka
      - postgres
    environment:
      - KAFKA_BROKERS=event-store:2113
      - DB_HOST=postgres
      - BATCH_SIZE=50

  postgres:
    image: postgres:15
    volumes:
      - pgdata:/var/lib/postgresql/data

  kafka:
    image: confluentinc/cp-kafka:7.5
    environment:
      - KAFKA_BROKER_ID=1
      - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
      - KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092
      - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092

volumes:
  eventstore-data:
  pgdata:

The system uses kafka as the event bus. Commands are published to a commands topic. Events are published to an events topic.

A systemctl unit for order-processor:

[Unit]
Description=Order Processor Service
After=network.target

[Service]
Type=simple
User=app
ExecStart=/usr/local/bin/order-processor --batch-size=50 --kafka-brokers=kafka:9092
Restart=always
RestartSec=10s
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Key Patterns and Variants

Read Model Caching

The query model can be cached using Redis or in-memory caches. A GetOrderSummary may first check Redis before querying PostgreSQL.

GET /queries/order-summary?orderId=order-123

If the result is not in cache, it queries the read model and stores it in Redis with a TTL of 5 minutes.

Query Projection

A query may be built from multiple sources. For example, GetUserWithOrders may:

This is a projection. Projections can be materialized or computed on-demand.

Event Sourcing as a Foundation

CQRS is often combined with event sourcing: the command model is built from an event stream. Every state change is recorded as an event.

This enables:

Command-Query Split in APIs

The system may expose two distinct APIs:

Clients use different endpoints based on intent.

Synchronous and Asynchronous Commands

Commands can be:

Synchronous commands are simpler but add latency. Asynchronous commands are better for high-throughput systems.

Summary

CQRS is not just a pattern—it is a system architecture. It requires careful handling of events, idempotent commands, and eventual consistency. The system breaks when events are lost, duplicated, or misordered. The read model becomes stale. Queries return outdated data. But when it works, it delivers high performance, scalability, and flexibility.

The core insight is this: separate what you write from what you read. And when you do, you gain the ability to evolve both models independently.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.