CQRS Pattern
Separate read and write models to optimize for different access patterns. Covers command-query separation, read model projections, event-driven synchronization, eventual consistency, and the patterns that scale read-heavy and write-heavy workloads independently.
CQRS (Command Query Responsibility Segregation) separates the write model from the read model. Instead of a single database handling both reads and writes (and doing neither optimally), CQRS lets you optimize each independently. Writes go to a normalized model optimized for consistency. Reads come from denormalized models optimized for queries.
Why CQRS
Traditional CRUD:
Single Model: Users ←JOIN→ Orders ←JOIN→ Products
Write: INSERT INTO orders (user_id, product_id, quantity, ...)
Read: SELECT o.*, u.name, p.title, p.price
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN products p ON o.product_id = p.id
WHERE u.id = 123
ORDER BY o.created_at DESC
Problem: Same schema serves writes (normalized) AND reads (needs JOINs)
3 JOINs on every read → slow at scale
CQRS:
Write Model: Normalized tables (users, orders, products)
Read Model: Denormalized view (order_details_view)
- Pre-joined, pre-computed
- Instant reads, no JOINs
- Updated asynchronously when write model changes
Architecture
Commands (writes) Queries (reads)
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Command │ │ Query │
│ Handler │ │ Handler │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Write Model │──Events───→ │ Read Model │
│ (PostgreSQL) │ │ (ElasticSearch│
│ Normalized │ │ or Redis) │
└──────────────┘ └──────────────┘
Write path: Validate → Apply → Store → Publish event
Read path: Query denormalized read model (fast, no JOINs)
Sync: Events update read model asynchronously
Implementation
# Command side: Handle writes
class CreateOrderCommand:
user_id: str
items: list[OrderItem]
class CreateOrderHandler:
def handle(self, command: CreateOrderCommand):
# Validate business rules
user = self.user_repo.get(command.user_id)
if not user.is_active:
raise BusinessError("User account is inactive")
# Create aggregate
order = Order.create(
user_id=command.user_id,
items=command.items,
)
# Persist to write store
self.order_repo.save(order)
# Publish domain events
self.event_bus.publish(OrderCreated(
order_id=order.id,
user_id=order.user_id,
items=order.items,
total=order.total,
))
# Query side: Handle reads
class OrderReadModel:
"""Denormalized read model updated by events."""
def get_user_orders(self, user_id: str):
# No JOINs! Pre-denormalized data
return self.read_store.query(
"SELECT * FROM order_details WHERE user_id = %s ORDER BY created_at DESC",
user_id
)
# Event handler: Sync read model
class OrderReadModelUpdater:
def handle(self, event: OrderCreated):
# Denormalize and store in read model
user = self.user_service.get(event.user_id)
for item in event.items:
product = self.product_service.get(item.product_id)
self.read_store.upsert("order_details", {
"order_id": event.order_id,
"user_id": event.user_id,
"user_name": user.name,
"product_name": product.name,
"quantity": item.quantity,
"price": product.price,
"total": event.total,
"created_at": event.timestamp,
})
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| CQRS for simple CRUD | Unnecessary complexity | CQRS only when read/write patterns diverge significantly |
| Synchronous read model updates | Write latency includes read model update | Async events for read model sync |
| No eventual consistency strategy | UI shows stale data confusingly | ”Your order is being processed” patterns |
| Shared database for read/write | Defeats the purpose | Separate stores, separate optimization |
| Too many read models | Maintenance overhead | One read model per query pattern, not per screen |
CQRS is powerful but not free. It adds complexity, eventual consistency, and operational overhead. Use it when your read and write patterns are fundamentally different — not for every service.