Skip to content
Codeloom
System Design

CQRS Explained: Command Query Responsibility Segregation

Learn how CQRS separates read and write models for better scalability. Covers architecture, implementation patterns, and when CQRS is worth the complexity.

·7 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What CQRS is and how it separates reads from writes
  • Implement separate command and query models
  • Understand eventual consistency between write and read sides
  • Decide when CQRS is worth the added complexity

Prerequisites

  • Basic understanding of REST APIs and databases
  • Familiarity with microservices concepts

Most applications use the same data model for reading and writing. The same database tables, the same ORM entities, the same service layer. This works until it does not. When your read patterns diverge sharply from your write patterns, a single model becomes a compromise that serves neither well. CQRS (Command Query Responsibility Segregation) addresses this by splitting the application into two sides: one optimized for writes (commands) and one optimized for reads (queries).

The Problem with a Unified Model

Consider an e-commerce product catalog. The write side handles operations like “add product,” “update price,” and “adjust inventory.” The read side handles queries like “show product page,” “list products by category with average rating,” and “search products with filters.”

Unified model problems:

  Write needs:                    Read needs:
  - Normalized tables             - Denormalized views
  - Row-level locking             - No locking
  - Validation and constraints    - Fast aggregations
  - Transactional consistency     - Search indexes
  - Low throughput (hundreds/s)   - High throughput (thousands/s)

A normalized schema optimized for writes makes read queries slow because they require joins across many tables. A denormalized schema optimized for reads makes writes complex because updates must propagate to multiple places. CQRS lets you optimize each side independently.

CQRS Architecture

┌────────────────┐                    ┌────────────────┐
│   Commands     │                    │   Queries      │
│  (create,      │                    │  (list, get,   │
│   update,      │                    │   search,      │
│   delete)      │                    │   aggregate)   │
└───────┬────────┘                    └───────┬────────┘
        │                                     │
  ┌─────┴──────┐                      ┌───────┴──────┐
  │  Command   │                      │   Query      │
  │  Handler   │                      │   Handler    │
  └─────┬──────┘                      └───────┬──────┘
        │                                     │
  ┌─────┴──────┐    sync/async        ┌───────┴──────┐
  │  Write     │ ──────────────────>  │   Read       │
  │  Database  │    projection        │   Database   │
  │ (Postgres) │                      │ (Elasticsearch│
  └────────────┘                      │  / Redis)    │
                                      └──────────────┘

The write side accepts commands, validates them, applies business rules, and persists changes to a write-optimized store. The read side maintains one or more read-optimized views (projections) that are updated whenever the write side changes.

Implementation: Separate Models

Command Side

Commands represent intentions to change state. They are validated, and business rules are enforced before persisting.

# Command model - focused on business rules and validation
from dataclasses import dataclass
from decimal import Decimal

@dataclass
class CreateProductCommand:
    name: str
    price: Decimal
    category_id: str
    stock: int

class ProductCommandHandler:
    def __init__(self, write_repo, event_publisher):
        self.write_repo = write_repo
        self.event_publisher = event_publisher

    def handle_create(self, cmd: CreateProductCommand):
        # Validate business rules
        if cmd.price <= 0:
            raise ValueError("Price must be positive")
        if cmd.stock < 0:
            raise ValueError("Stock cannot be negative")

        # Persist to write store
        product = self.write_repo.create(
            name=cmd.name,
            price=cmd.price,
            category_id=cmd.category_id,
            stock=cmd.stock
        )

        # Publish event for read side to consume
        self.event_publisher.publish("ProductCreated", {
            "product_id": product.id,
            "name": product.name,
            "price": str(product.price),
            "category_id": product.category_id,
            "stock": product.stock
        })

        return product.id

Query Side

The query side maintains denormalized views optimized for specific read patterns. No business logic lives here.

# Query model - optimized for read patterns
class ProductQueryHandler:
    def __init__(self, read_store):
        self.read_store = read_store  # e.g., Elasticsearch

    def get_product(self, product_id: str):
        return self.read_store.get("products", product_id)

    def search_products(self, query: str, category: str = None,
                        min_price: float = None, max_price: float = None):
        filters = []
        if category:
            filters.append({"term": {"category": category}})
        if min_price is not None:
            filters.append({"range": {"price": {"gte": min_price}}})
        if max_price is not None:
            filters.append({"range": {"price": {"lte": max_price}}})

        return self.read_store.search("products", query, filters)

    def get_category_summary(self, category_id: str):
        # Pre-aggregated data, no expensive joins
        return self.read_store.get("category_summaries", category_id)

Projection (Synchronization)

A projection listens to events from the write side and updates the read store:

class ProductProjection:
    def __init__(self, read_store):
        self.read_store = read_store

    def handle_event(self, event):
        match event["type"]:
            case "ProductCreated":
                self.read_store.index("products", event["product_id"], {
                    "name": event["name"],
                    "price": float(event["price"]),
                    "category": event["category_id"],
                    "stock": event["stock"],
                    "created_at": event["timestamp"]
                })
            case "PriceUpdated":
                self.read_store.update("products", event["product_id"], {
                    "price": float(event["new_price"])
                })
            case "StockAdjusted":
                self.read_store.update("products", event["product_id"], {
                    "stock": event["new_stock"]
                })

Synchronization Strategies

The write store and read store must stay in sync. There are three approaches:

Synchronous update

Update both stores in the same request. Simple but couples read and write performance.

Command -> Write to DB -> Update read store -> Return response

If the read store is slow (Elasticsearch indexing), the write latency increases.

Asynchronous via events

Publish an event after the write commits. A separate consumer updates the read store. This is the most common approach.

Command -> Write to DB -> Publish event -> Return response
                                |
                          (asynchronously)
                                |
                          Consumer -> Update read store

The read store is eventually consistent. There is a window (typically milliseconds to low seconds) where a write has committed but the read store has not yet been updated.

Change data capture

Use CDC tools like Debezium to capture changes from the write database’s transaction log and push them to the read store. No application-level event publishing needed.

Write DB (WAL) -> Debezium -> Kafka -> Consumer -> Read store

Handling Eventual Consistency

The read model may lag behind the write model. This creates UX challenges. Strategies:

Read your own writes: after a command succeeds, redirect the user to a page that reads from the write store (or cache the written data client-side) rather than the potentially stale read store.

Causal consistency tokens: the command returns a version token. The query includes this token, and the read side waits until it has processed at least that version.

# Command returns a version
result = command_handler.handle_create(cmd)
# result = { "product_id": "abc", "version": 42 }

# Query waits for that version
product = query_handler.get_product("abc", min_version=42)

Optimistic UI: the frontend assumes the write succeeded and shows the updated state immediately, reconciling later if the read store disagrees.

CQRS Without Event Sourcing

CQRS and event sourcing are often discussed together, but they are independent patterns. You can use CQRS with a regular CRUD database on the write side:

Simple CQRS (no event sourcing):
  Write side: PostgreSQL (normalized, transactional)
  Read side: PostgreSQL read replica + materialized views

Medium CQRS:
  Write side: PostgreSQL
  Read side: Elasticsearch for search, Redis for hot data

Full CQRS + Event Sourcing:
  Write side: Event store (append-only log)
  Read side: Multiple projections (Elasticsearch, Redis, analytics DB)

Start simple. A read replica with materialized views is often enough.

When CQRS Makes Sense

CQRS adds complexity. Use it when:

  • Read and write patterns differ significantly: your write model is normalized but reads need denormalized, pre-aggregated data.
  • Read and write loads differ: reads outnumber writes 100:1 and you need to scale them independently.
  • Multiple read representations: the same data needs to appear in search indexes, caches, and analytics stores.
  • Complex domain logic: the write side has rich validation and business rules that would clutter query code.

Do not use CQRS for simple CRUD applications where reads and writes use the same shape and scale similarly.

Key Takeaways

CQRS separates your application into a command side optimized for writes and a query side optimized for reads. This lets you scale, index, and model each side independently. The cost is operational complexity: you maintain two data stores, a synchronization mechanism, and must handle eventual consistency. Start with the simplest form (read replicas and views) and evolve toward separate stores only when the read and write patterns genuinely demand it.