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.
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.
Related articles
- System Design Consistent Hashing Deep Dive: Virtual Nodes and Rebalancing
Go beyond basic consistent hashing. Learn how virtual nodes solve imbalance, how rebalancing works during scale events, and real-world usage in Cassandra and DynamoDB.
- System Design Event Sourcing Explained: Architecture and Trade-offs
Understand event sourcing fundamentals, how it differs from CRUD, when to use it, and the real-world trade-offs you need to consider before adopting it.
- System Design Designing Rate Limiters: A System Design Deep Dive
A senior-engineer guide to designing rate limiters: algorithms, distributed coordination, trade-offs, and production patterns that actually scale.
- System Design Event-Driven Architecture: The Pragmatic Introduction
What event-driven architecture really gives you, when to choose it, and the operational realities of running asynchronous systems at scale.