CQRS, or Command Query Responsibility Segregation, is often presented as a pattern for improving scalability, but its real power lies in how it forces you to decouple how you get data from how you change it.
Let’s see this in action. Imagine an e-commerce system. On the write side, we have commands like PlaceOrder or UpdateProductStock. These are discrete actions that change the system’s state. On the read side, we have queries like GetOrderDetails(orderId) or ListProductsByCategory(categoryId). These just retrieve data, and often need to be optimized for speed and specific presentation needs.
Here’s a simplified OrderService handling commands. Notice how it focuses solely on validating and persisting the order state.
class OrderService:
def __init__(self, event_store):
self.event_store = event_store
def place_order(self, customer_id, items):
# Business logic for order placement
order_id = generate_unique_id()
order_placed_event = OrderPlacedEvent(order_id, customer_id, items)
self.event_store.save_event(order_placed_event)
return order_id
Now, consider a ProductQueryService that retrieves product information for a catalog page. This query might need to join data from multiple tables or even other services, and its performance is critical for user experience.
class ProductQueryService:
def __init__(self, read_database_connection):
self.db = read_database_connection
def list_products_by_category(self, category_id, page_number=1, page_size=20):
offset = (page_number - 1) * page_size
query = """
SELECT p.id, p.name, p.price, c.name as category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.category_id = %s
LIMIT %s OFFSET %s
"""
cursor = self.db.cursor()
cursor.execute(query, (category_id, page_size, offset))
return cursor.fetchall()
The core problem CQRS solves is the impedance mismatch between transactional write operations and read-heavy query operations. A single data model optimized for writes (e.g., normalized relational database) is often terrible for reads (requiring many joins). Conversely, a read-optimized model (e.g., denormalized document or wide-column store) can make writes complex and slow. CQRS allows you to use entirely different models, and even different databases, for each. The write model can be an event store or a transactional database optimized for ACID writes. The read model can be a set of denormalized views, a search index, or a specialized data store, each tailored for specific query patterns.
This separation means you can scale them independently. If your PlaceOrder command becomes a bottleneck, you can scale up your write database or add more instances of your command handlers. If users are complaining about slow product listings, you can scale your read replica database, add caching layers, or even switch to a faster read store without touching your write infrastructure. The "glue" between these two models is typically an event bus or message queue, where write-side events are published and then consumed by projections that update the read models.
The surprising thing is that for many systems, the "read model" doesn’t need to be a single, unified database. You can have multiple, specialized read models. For instance, a ProductQueryService might query a SQL database for basic product listings, while a separate ProductSearchService queries an Elasticsearch index for full-text search capabilities. Each read model is built and maintained by subscribing to the same set of write-side events. This allows you to evolve your read-facing capabilities without impacting your write operations, and vice versa.
The next step after implementing CQRS is often exploring how to handle eventual consistency guarantees and build robust reconciliation mechanisms for your read models.