Skip to main content

CQRS and Read Models

Separate read and write models to optimize for different access patterns and enable flexible data transformation.

TL;DR

CQRS (Command Query Responsibility Segregation) separates read and write models. Commands (writes) go to one optimized database; queries (reads) go to a separate read model optimized for access patterns. The read model is eventually consistent—it's updated asynchronously from the write model via events. This enables independent scaling: if reads are heavy, scale the read model without impacting writes. Use read models (materialized views) to denormalize data for specific queries, avoiding expensive joins. CQRS adds complexity—eventually consistent reads and multiple data stores—but the scalability and query flexibility are worth it for read-heavy systems.

Learning Objectives

  • Understand CQRS and the separation of reads and writes
  • Design write-optimized and read-optimized data models
  • Implement materialized views as read models
  • Handle eventual consistency in read models
  • Scale reads independently from writes
  • Manage consistency between write and read models

Motivating Scenario

A platform needs to display dashboards with complex aggregations: count orders by region, sum revenue by product, calculate customer lifetime value. These queries are expensive on the operational database. Meanwhile, write throughput is increasing. How do you optimize for both patterns when a single database can't excel at both?

Core Concepts

Write Model vs. Read Model

The write model (command side) is optimized for consistency and transactions: normalized schema, ACID properties. The read model (query side) is optimized for access patterns: denormalized, possibly eventual consistency. A single fact changes in one place; readers see eventually consistent snapshots.

Materialized Views

Rather than computing complex queries at read time, compute them once and store the result (materialized view). When data changes, recompute the view asynchronously. Readers always get fast, precomputed results. This trades storage and computation for query speed.

Event-Driven Synchronization

Events from the write model trigger updates to the read model. For example, "OrderCreated" event triggers updating read models for orders list, customer dashboard, analytics aggregations. Synchronization is asynchronous, so reads lag behind writes slightly.

Independent Scaling

With separate models, you scale each independently. Many reads? Add read model replicas. Many writes? Scale write model horizontally. This flexibility is impossible with a single database.

Practical Example

# ❌ POOR - Single model for reads and writes
class OrderService:
def create_order(self, user_id, items):
order = Order(user_id=user_id, items=items)
db.insert(order)
return order

def get_orders_by_region(self, region):
# Expensive query: join orders, users, and aggregate
return db.query('''
SELECT region, COUNT(*) as count, SUM(total) as revenue
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.region = %s
GROUP BY region
''', region)

# ✅ EXCELLENT - CQRS with separate read and write models
class CommandService:
"""Write model - optimized for consistency"""
def __init__(self, write_db, event_bus):
self.db = write_db
self.event_bus = event_bus

def create_order(self, user_id, items):
with self.db.transaction():
order = Order(user_id=user_id, items=items)
self.db.insert(order)
# Emit event for read model
self.event_bus.publish('OrderCreated', {
'order_id': order.id,
'user_id': user_id,
'items': items,
'total': calculate_total(items)
})
return order

class QueryService:
"""Read model - optimized for queries"""
def __init__(self, read_db):
self.db = read_db

def get_orders_by_region(self, region):
# Fast query on materialized view - no joins needed
return self.db.query('''
SELECT region, order_count, total_revenue
FROM orders_by_region_view
WHERE region = %s
''', region)

def get_customer_dashboard(self, user_id):
# Pre-aggregated data in read model
return self.db.query('''
SELECT order_count, lifetime_value, last_order_date
FROM customer_dashboard_view
WHERE user_id = %s
''', user_id)

class ReadModelUpdater:
"""Maintains read models from events"""
def __init__(self, read_db, event_bus):
self.db = read_db
self.event_bus = event_bus

def on_order_created(self, event):
with self.db.transaction():
# Update orders list
self.db.insert('orders_view', {
'order_id': event['order_id'],
'user_id': event['user_id'],
'items': event['items'],
'total': event['total']
})

# Update regional aggregation
self.db.execute('''
UPDATE orders_by_region_view
SET order_count = order_count + 1,
total_revenue = total_revenue + %s
WHERE region = (SELECT region FROM users WHERE id = %s)
''', event['total'], event['user_id'])

# Update customer dashboard
self.db.execute('''
UPDATE customer_dashboard_view
SET order_count = order_count + 1,
lifetime_value = lifetime_value + %s,
last_order_date = NOW()
WHERE user_id = %s
''', event['total'], event['user_id'])

When to Use / When Not to Use

When to Use CQRS
  1. Read-heavy systems with complex queries and aggregations
  2. Systems with different read and write scaling requirements
  3. Multiple clients needing different data representations
  4. When eventual consistency is acceptable for reads
  5. Systems using event sourcing
When NOT to Use CQRS
  1. Simple CRUD applications with equal read/write loads
  2. Systems requiring strong consistency for all reads
  3. Early-stage applications (adds complexity)
  4. When operational database already supports queries efficiently
  5. Teams lacking operational maturity for multiple databases

Patterns and Pitfalls

Design Review Checklist

  • Read model is optimized for specific query patterns
  • Updates to read model are triggered by events from write model
  • Eventual consistency is acceptable for your use case
  • Monitoring tracks lag between write and read model updates
  • Read model can be rebuilt from events if corrupted
  • Queries are demonstrably faster on read model than write model
  • Multiple databases and their synchronization are well documented

Self-Check

  • What's the relationship between CQRS and event sourcing?
  • How do you keep read models eventually consistent with write models?
  • When would you use CQRS over a single database with good indexing?
One Takeaway

CQRS is a scaling technique, not a design fundamental. Use it when your read and write patterns are so different that optimizing for both is impossible with a single database.

Next Steps

  • Implement materialized views for your most expensive queries
  • Set up event handlers to update read models asynchronously
  • Monitor the lag between write model and read model updates
  • Design rebuild processes to recover from read model corruption

References

  • Martin Fowler, Command Query Responsibility Segregation (CQRS)
  • Chris Richardson, Microservices Patterns: Pattern Language for Microservices
  • Greg Young, CQRS Documents