StackPractices
advanced By Mathias Paulenko

CQRS Pattern

Separate read and write models to optimize each for its own workload — when CQRS pays off, when it's overkill, and how it pairs with event sourcing.

Overview

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates the models used for writing data from the models used for reading it. Instead of a single data model handling both commands and queries, CQRS uses a Command Model for writes and a Query Model for reads, each optimized for its own workload.

The idea comes from Bertrand Meyer’s CQS principle — a method should either change state (command) or return data (query), never both. Greg Young scaled that method-level rule up to whole models around 2010, giving it the CQRS name. The canonical implementation pairs it with event sourcing: events from the write side become the source data for read-model projections.

A warning up front: CQRS is one of the most over-applied patterns in enterprise software. Martin Fowler’s bliki says it plainly — it belongs at the edges of a system where read and write needs genuinely diverge, not across an entire codebase.

When to Use

Use the CQRS Pattern when:

  • Read and write workloads have very different performance characteristics or scale independently
  • You need several read models for the same data (search, reports, API listings) without complicating the write model
  • Event sourcing is already in use, making read-model projections natural
  • Different teams own reads vs. writes, and decoupling reduces coordination
  • Examples: analytics dashboards, e-commerce catalogs, social media feeds, reporting systems

When to Avoid

  • Simple CRUD apps — if reads and writes look alike, one model is strictly simpler
  • Balanced read/write ratios — the payoff comes from divergent needs, not from the pattern itself
  • Small teams — projections, rebuilds, and event consumers are real operational surface
  • Systems where eventual consistency is unacceptable — a read model that lags 200ms can be a dealbreaker for users who just saved something
  • “It’s a microservice, so it needs CQRS” — per-service CRUD doesn’t become CQRS by being distributed

Two Levels of CQRS

The pattern has two distinct flavors, and most of the confusion comes from mixing them:

LevelWhat separatesComplexityWhen it’s enough
Simple CQRSSeparate models/objects, same databaseLowMost apps that “do CQRS”
Full CQRSSeparate models AND separate stores, sync via eventsHighDivergent scale, audit, multiple projections
flowchart diagram: subgraph WRITE[

Start with the left level. The jump to separate stores is a one-way door — a write store plus N read stores plus a bus plus projections is the full event-driven machinery, not just “a bit more architecture”.

Solution

Python

from dataclasses import dataclass, field
from typing import List, Dict
from datetime import datetime

# Write Model (Command Side)
@dataclass
class Order:
    order_id: str
    customer_id: str
    items: List[dict] = field(default_factory=list)
    status: str = "pending"
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())

class OrderCommandHandler:
    def __init__(self):
        self.orders: Dict[str, Order] = {}

    def create_order(self, order_id: str, customer_id: str) -> Order:
        order = Order(order_id=order_id, customer_id=customer_id)
        self.orders[order_id] = order
        return order

    def add_item(self, order_id: str, product: str, qty: int, price: float):
        order = self.orders[order_id]
        order.items.append({"product": product, "qty": qty, "price": price})

    def confirm(self, order_id: str):
        self.orders[order_id].status = "confirmed"

# Read Model (Query Side) — denormalized DTO optimized for reads
@dataclass
class OrderSummary:
    order_id: str
    customer_id: str
    total: float
    item_count: int
    status: str

class OrderQueryHandler:
    def __init__(self, command_store: Dict[str, Order]):
        self.command_store = command_store

    def get_summary(self, order_id: str) -> OrderSummary:
        order = self.command_store[order_id]
        total = sum(i["qty"] * i["price"] for i in order.items)
        return OrderSummary(
            order_id=order.order_id,
            customer_id=order.customer_id,
            total=total,
            item_count=len(order.items),
            status=order.status
        )

    def list_by_customer(self, customer_id: str) -> List[OrderSummary]:
        return [
            self.get_summary(o.order_id)
            for o in self.command_store.values()
            if o.customer_id == customer_id
        ]

# Usage
commands = OrderCommandHandler()
commands.create_order("ORD-1", "CUST-1")
commands.add_item("ORD-1", "Laptop", 1, 999.99)
commands.add_item("ORD-1", "Mouse", 2, 29.99)
commands.confirm("ORD-1")

queries = OrderQueryHandler(commands.orders)
summary = queries.get_summary("ORD-1")
print(f"Order {summary.order_id}: ${summary.total:.2f} ({summary.item_count} items)")

JavaScript

// Write Model (Command Side)
class OrderCommandHandler {
  constructor() {
    this.orders = new Map();
  }

  createOrder(orderId, customerId) {
    this.orders.set(orderId, {
      orderId, customerId, items: [], status: "pending",
      createdAt: new Date().toISOString()
    });
  }

  addItem(orderId, product, qty, price) {
    this.orders.get(orderId).items.push({ product, qty, price });
  }

  confirm(orderId) {
    this.orders.get(orderId).status = "confirmed";
  }
}

// Read Model (Query Side)
class OrderQueryHandler {
  constructor(commandStore) {
    this.store = commandStore;
  }

  getSummary(orderId) {
    const order = this.store.get(orderId);
    const total = order.items.reduce((sum, i) => sum + i.qty * i.price, 0);
    return {
      orderId: order.orderId,
      customerId: order.customerId,
      total,
      itemCount: order.items.length,
      status: order.status
    };
  }

  listByCustomer(customerId) {
    return Array.from(this.store.values())
      .filter(o => o.customerId === customerId)
      .map(o => this.getSummary(o.orderId));
  }
}

// Usage
const commands = new OrderCommandHandler();
commands.createOrder("ORD-1", "CUST-1");
commands.addItem("ORD-1", "Laptop", 1, 999.99);
commands.confirm("ORD-1");

const queries = new OrderQueryHandler(commands.orders);
console.log(queries.getSummary("ORD-1"));

Java

import java.util.*;

// Write Model — domain entity enforces invariants
class Order {
    String orderId;
    String customerId;
    List<Item> items = new ArrayList<>();
    String status = "pending";
    String createdAt = new Date().toString();

    record Item(String product, int qty, double price) {}

    void confirm() {
        if (items.isEmpty()) throw new IllegalStateException("Cannot confirm empty order");
        this.status = "confirmed";
    }
}

class OrderCommandHandler {
    private final Map<String, Order> orders = new HashMap<>();

    public void createOrder(String orderId, String customerId) {
        Order o = new Order();
        o.orderId = orderId;
        o.customerId = customerId;
        orders.put(orderId, o);
    }

    public void addItem(String orderId, String product, int qty, double price) {
        orders.get(orderId).items.add(new Order.Item(product, qty, price));
    }

    public void confirm(String orderId) {
        orders.get(orderId).confirm();
    }

    public Map<String, Order> getStore() { return orders; }
}

// Read Model — flat DTO, no behavior
class OrderSummary {
    public String orderId;
    public String customerId;
    public double total;
    public int itemCount;
    public String status;
}

class OrderQueryHandler {
    private final Map<String, Order> store;

    public OrderQueryHandler(Map<String, Order> store) {
        this.store = store;
    }

    public OrderSummary getSummary(String orderId) {
        Order o = store.get(orderId);
        OrderSummary s = new OrderSummary();
        s.orderId = o.orderId;
        s.customerId = o.customerId;
        s.total = o.items.stream().mapToDouble(i -> i.qty() * i.price()).sum();
        s.itemCount = o.items.size();
        s.status = o.status;
        return s;
    }
}

// Usage — file: CqrsDemo.java
public class CqrsDemo {
    public static void main(String[] args) {
        OrderCommandHandler commands = new OrderCommandHandler();
        commands.createOrder("ORD-1", "CUST-1");
        commands.addItem("ORD-1", "Laptop", 1, 999.99);
        commands.confirm("ORD-1");

        OrderQueryHandler queries = new OrderQueryHandler(commands.getStore());
        System.out.println(queries.getSummary("ORD-1").total);
    }
}

Explanation

CQRS splits two concerns that a single model forces together:

  • Command Model: optimized for writes — validates business rules, enforces invariants, processes state changes. Often maps closely to the domain.
  • Query Model: optimized for reads — denormalized, shaped by what the UI or API consumer needs, often stored in a separate database (Elasticsearch for search, Redis for fast lookups).

The two models synchronize either synchronously (same transaction — simple CQRS) or asynchronously (event-driven projections — full CQRS). In the async variant, writes publish events and projection handlers rebuild the read model:

// Projection handler — rebuilds the read model from events
class OrderProjectionHandler {
  constructor(private readDb: ReadDatabase) {}

  async handle(event: OrderEvent): Promise<void> {
    switch (event.type) {
      case 'OrderCreated':
        await this.readDb.orders.insert({
          orderId: event.orderId,
          customerId: event.customerId,
          total: event.items.reduce((s, i) => s + i.price * i.quantity, 0),
          status: 'pending',
          createdAt: event.timestamp
        });
        break;
      case 'OrderConfirmed':
        await this.readDb.orders.update(
          { orderId: event.orderId },
          { status: 'confirmed', confirmedAt: event.timestamp }
        );
        break;
    }
  }
}

Consistency Models

Choosing the sync level is the real architectural decision:

ModelHowTrade-off
StrongRead and write in the same transactionSimple, but defeats independent scaling
EventualRead model updates asynchronouslyLatency gap — users may not see their own writes
Read-your-writesRoute recent reads to the write model temporarilyBest UX, adds routing complexity

Read-your-writes is the usual answer to the refresh problem: after a write, serve that user’s reads from the write store for a few seconds while the projection catches up.

Variants

VariantDescriptionUse Case
Single DBSeparate models, same databaseSimple CQRS; lower complexity
Dual DBWrite to SQL, read from NoSQL/searchComplex queries; high read scale
Event Sourcing + CQRSEvents are source of truth; read models are projectionsAudit trails; temporal queries
API SegregationSeparate REST/GraphQL endpoints for commands and queriesMicroservices; team boundaries

Read-model storage choices map to query shape more than to preference:

Read modelBest forStorage
Materialized ViewPre-computed aggregatesDocument DB
Search IndexFull-text, faceted searchElasticsearch
Graph ProjectionRelationship queriesNeo4j
CacheHot, short-lived readsRedis
Event StreamReal-time analyticsKafka/Kinesis

What Works

  • Start simple — separate models within the same database before introducing dual storage
  • Use eventual consistency for read models when async projections are acceptable
  • Version your read models when the query schema changes — a rebuild is cheaper than a migration
  • Monitor projection lag — read models drifting too far behind writes break user trust silently
  • Keep commands small and focused — one command should do one thing
  • Make consumers idempotent — replayed events will hit your projections more than once

Common Mistakes

  • Premature separation — adding CQRS to simple CRUD buys complexity, nothing else. It’s the single most common CQRS failure.
  • Letting the read model bypass business rules — validation belongs in commands; a read-side shortcut around an invariant corrupts the write side later.
  • Ignoring the refresh bug — user saves, hits F5, doesn’t see the change (projection lag). Without read-your-writes or optimistic UI, users report it as a bug.
  • Read-model explosion — one projection per endpoint turns the codebase into a projection maintenance project. Consolidate shapes.
  • Trying to make separate stores strongly consistent — that’s a distributed transaction, the exact thing CQRS exists to avoid. Accept eventual consistency or stay on one store.
  • Forgetting rebuilds — projection logic changes; if you can’t replay events (or rebuild from snapshots), the read model can’t be fixed.

Real-World Example — E-commerce Orders

A TypeScript order system on Kafka + Elasticsearch, with read-your-writes:

Write:  POST /api/orders -> CreateOrderCommand -> Order aggregate (PostgreSQL)
Read:   GET  /api/orders?q=laptop -> OrderQueryHandler -> Elasticsearch
Sync:   OrderCreated/Confirmed events -> Kafka topic -> projection consumer

Read-your-writes flow:
  1. Create returns the order ID immediately
  2. Frontend renders the new order from local state (optimistic)
  3. Poll GET /api/orders/:id until the projection lands (~50-200ms lag)
  4. 5s timeout → fall back to the write model

Typical sizing:
  Write:  2 PostgreSQL (primary + replica), p95 < 50ms
  Read:   3 Elasticsearch nodes, p95 < 20ms
  Bus:    Kafka, 12 partitions
  Lag:    < 500ms

The pattern pays for itself here because search/filters hit Elasticsearch — a query the write model couldn’t serve well — while the aggregate keeps invariants in Postgres.

Troubleshooting

  • Read model shows stale data: projection lag or a dead consumer — check consumer group offsets and the projection handler logs.
  • Read model drifted and can’t be trusted: replay events (stop consumer → truncate read store → replay). For big stores, run a shadow projection alongside the current one, verify, then switch.
  • “User saved but can’t see it”: that’s the refresh bug — add read-your-writes routing or optimistic UI, not faster sync.
  • Projections exploding in number: merge overlapping shapes; keep one read model per genuinely distinct query family.
  • Commands failing silently after write: events emitted but consumers down — add DLQ/alerting on projection consumers, and make events idempotent.

Production Notes

  • Track projection lag as a first-class metric (target under ~500ms) — it’s the canary for the whole read side.
  • Version every projection’s schema; a bump should trigger a rebuild, not a hand migration.
  • Projection consumers must be idempotent — replays and redeliveries are normal.
  • Keep the write model lean; read-side concerns (indexes, search fields, denormalized shapes) belong on the query side.
  • Document when to stop using CQRS on each bounded context — it’s a per-context decision, not a default.

Key Takeaways

  • CQRS separates writes (commands, invariants) from reads (queries, denormalized shapes) — the split is about models, not necessarily stores.
  • There are two levels: same-DB (cheap, most teams) and dual-store + events (expensive, justified only by divergent scale).
  • Eventual consistency is the price; read-your-writes is usually the mitigation.
  • The most common mistake is applying it where CRUD would do — match the pattern to the divergence, not to the buzzword.

Further Reading

Frequently Asked Questions

Does CQRS require Event Sourcing?

No — the two are independent. Event sourcing pairs naturally because events are perfect projection input, but CQRS works fine with a single relational store: separate models + same transaction.

How do read and write models stay in sync?

In single-DB CQRS, in the same transaction. In dual-store CQRS, write events and let projection consumers rebuild the read model — accept the lag or add read-your-writes.

When should I use separate databases?

Only when the read and write sides genuinely can't share a store — different scaling needs (search vs. ACID), different availability requirements, or a read shape the write model can't serve (aggregates, full-text). Otherwise the extra store is operational debt.

What breaks when a read model gets corrupted?

Nothing on the write side — that's the point. The fix is a rebuild: stop consumers, truncate the read store, replay events. If you can't replay, you don't have event sourcing and rebuilds get harder.

How do I handle the lag in read models?

Three options: read-your-writes routing for the writer's own reads, optimistic UI updates that reflect the change immediately, or polling with a version check until the projection lands.

Is CQRS still relevant without microservices?

Yes — the pattern is about separating read/write models, which pays off in a monolith too (a reporting view next to a domain model). Microservices just make the store separation easier, not the pattern more necessary.