Command Query Responsibility Segregation (CQRS) is a pattern that separates read and write operations into different models. Instead of using a single model for both commands that change state and queries that read state, CQRS uses separate models optimized for each purpose. This article explains the pattern, its integration with event sourcing, materialized views, and when CQRS adds value versus when it overcomplicates.
Core Concept
In a traditional CRUD architecture, the same entity model handles both reads and writes:
class UserModel:
def get_user(self, user_id): ... # Query
def update_user(self, user_id, data): ... # Command
def create_user(self, data): ... # Command
In CQRS, these are split:
# Command side (writes)
class UserCommand:
def create_user(self, data): ...
def update_email(self, user_id, new_email): ...
def deactivate_user(self, user_id): ...
# Query side (reads)
class UserQuery:
def get_user_by_id(self, user_id): ...
def search_users(self, criteria): ...
def get_user_profile(self, user_id): ...
Why Separate?
The read and write paths have different requirements:
| Aspect | Command Side | Query Side |
|--------|-------------|------------|
| Focus | Business logic, validation, consistency | Performance, flexibility, denormalization |
| Data model | Normalized, domain-driven | Denormalized, view-optimized |
| Operations | Create, Update, Delete | Queries, aggregations |
| Consistency | ACID required | Eventual often acceptable |
| Scalability | Scale by domain partitioning | Scale by read replicas |
Implementation Patterns
Simple CQRS (Separate Models, Single Database)
The simplest CQRS implementation uses separate command and query models but a single database.
# Simple CQRS with separate models
from dataclasses import dataclass
# Command model
@dataclass
class OrderCommand:
db_connection: Database
def place_order(self, customer_id, items):
total = sum(item.price * item.quantity for item in items)
order_id = self.db_connection.execute(
"INSERT INTO orders (customer_id, total, status) VALUES (?, ?, 'pending')",
(customer_id, total)
)
for item in items:
self.db_connection.execute(
"INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (?, ?, ?, ?)",
(order_id, item.product_id, item.quantity, item.price)
)
return order_id
# Query model
@dataclass
class OrderQuery:
db_connection: Database
def get_order_summary(self, customer_id, page=1, page_size=20):
return self.db_connection.query(
"""SELECT o.id, o.created_at, o.total, o.status,
COUNT(oi.id) as item_count
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.customer_id = ?
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT ? OFFSET ?""",
(customer_id, page_size, (page - 1) * page_size)
)
CQRS with Separate Databases
For full separation, use different databases for commands and queries.
[Client] -> [Command Handler] -> [Write DB (PostgreSQL)]
[Client] -> [Query Handler] -> [Read DB (Elasticsearch / Redis)]
^
| [Sync mechanism: CDC, event bus, batch]
# Write side: PostgreSQL (normalized)
class OrderWriteRepository:
def save(self, order):
with self.conn.transaction():
self.conn.execute("INSERT INTO orders ...", order.data())
# Read side: Elasticsearch (denormalized for search)
class OrderReadRepository:
def save_projection(self, order):
self.es.index(index="orders", id=order.id, body=order.search_data())
def search(self, query):
return self.es.search(index="orders", body=query)
The sync mechanism keeps the read databases updated. This can use change data capture, domain events, or batch jobs.
Event Sourcing Integration
CQRS pairs naturally with event sourcing. Instead of storing the current state, event sourcing stores a sequence of events that led to the current state.
How It Works
# Event-sourced command handler
class AccountCommand:
def __init__(self, event_store):
self.event_store = event_store
def deposit(self, account_id, amount):
balance = self._get_current_balance(account_id)
event = MoneyDeposited(account_id, amount, balance + amount)
self.event_store.append(event)
def withdraw(self, account_id, amount):
balance = self._get_current_balance(account_id)
if balance < amount:
raise InsufficientFundsError()
event = MoneyWithdrawn(account_id, amount, balance - amount)
self.event_store.append(event)
def _get_current_balance(self, account_id):
events = self.event_store.get_events(account_id)
balance = 0
for event in events:
if isinstance(event, MoneyDeposited):
balance += event.amount
elif isinstance(event, MoneyWithdrawn):
balance -= event.amount
return balance
Materialized View from Events
Projections build read models by processing events:
# Projection: Building a read model from events
class AccountBalanceProjection:
def __init__(self, read_db):
self.read_db = read_db
def handle_deposited(self, event):
# Update the projection atomically
self.read_db.execute(
"""INSERT INTO account_summary (account_id, balance, last_updated)
VALUES (?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
balance = balance + ?,
last_updated = ?""",
(event.account_id, event.amount, event.timestamp,
event.amount, event.timestamp)
)
def handle_withdrawn(self, event):
self.read_db.execute(
"UPDATE account_summary SET balance = ?, last_updated = ? WHERE account_id = ?",
(event.new_balance, event.timestamp, event.account_id)
)
Materialized Views
Materialized views are denormalized, pre-computed read models. They make queries fast by storing data in a shape that matches the query.
-- Materialized view for order dashboards
CREATE MATERIALIZED VIEW monthly_customer_summary AS
SELECT
c.id as customer_id,
c.email,
DATE_TRUNC('month', o.created_at) as month,
COUNT(DISTINCT o.id) as order_count,
SUM(oi.quantity * oi.price) as total_spent,
AVG(oi.quantity * oi.price) as avg_order_value
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN order_items oi ON o.id = oi.order_id
GROUP BY c.id, c.email, DATE_TRUNC('month', o.created_at);
-- Queries against the materialized view are fast
SELECT * FROM monthly_customer_summary
WHERE customer_id = 42
ORDER BY month DESC;
Projection Design Principles
2. **Exactly-once semantics**: Track which events have been processed to avoid duplicates.
3. **Rebuildable**: Projections can be rebuilt from scratch by replaying all events.
4. **Versioned**: Schema changes to projections support backward compatibility.
When CQRS Adds Value
CQRS is not the right choice for every application. It adds significant complexity.
Use CQRS When
When NOT to Use CQRS
Common Pitfalls
Conclusion
CQRS separates read and write operations into different models, each optimized for its purpose. It pairs well with event sourcing, which stores events rather than state, and materialized views, which pre-compute read-optimized data shapes. CQRS adds substantial complexity, so use it only when your application genuinely benefits from independent read and write models. For most applications, start with a simple CRUD model and extract CQRS patterns when specific performance or modeling needs arise.