CQRS, or Command Query Responsibility Segregation, is often touted as a way to simplify complex domains, but it can just as easily become a labyrinth of indirection and boilerplate if applied without a clear understanding of its trade-offs.
Let’s watch CQRS in action with a simplified example. Imagine a Product service.
Initial State (No CQRS):
class Product:
def __init__(self, id, name, price, stock_count):
self.id = id
self.name = name
self.price = price
self.stock_count = stock_count
def update_price(self, new_price):
if new_price < 0:
raise ValueError("Price cannot be negative")
self.price = new_price
def add_stock(self, quantity):
if quantity < 0:
raise ValueError("Cannot add negative stock")
self.stock_count += quantity
def get_details(self):
return {"id": self.id, "name": self.name, "price": self.price, "stock_count": self.stock_count}
# Usage
product_repo = {} # In-memory store
product_id = "prod-123"
product_repo[product_id] = Product(product_id, "Gadget", 99.99, 100)
# Command
product_repo[product_id].update_price(109.99)
# Query
details = product_repo[product_id].get_details()
print(details)
This is straightforward. A single Product object handles both state changes (commands) and data retrieval (queries).
Introducing CQRS:
The core idea of CQRS is to separate the model that handles commands (the "write" side) from the model that handles queries (the "read" side). This often involves different data structures, different databases, or even different services.
Let’s refactor the above for CQRS. We’ll use separate classes for commands and queries.
Write Side (Commands):
from uuid import uuid4
class ProductCreated:
def __init__(self, product_id, name, price, initial_stock):
self.product_id = product_id
self.name = name
self.price = price
self.initial_stock = initial_stock
class UpdateProductPrice:
def __init__(self, product_id, new_price):
self.product_id = product_id
self.new_price = new_price
class AddProductStock:
def __init__(self, product_id, quantity):
self.product_id = product_id
self.quantity = quantity
# Command Handler
class ProductCommandHandler:
def __init__(self, event_store):
self.event_store = event_store
def handle(self, command):
if isinstance(command, ProductCreated):
event = ProductCreated(
product_id=str(uuid4()), # Generate ID on creation
name=command.name,
price=command.price,
initial_stock=command.initial_stock
)
self.event_store.append(event)
return event.product_id
elif isinstance(command, UpdateProductPrice):
event = ProductUpdatedPrice(
product_id=command.product_id,
new_price=command.new_price
)
self.event_store.append(event)
return True
elif isinstance(command, AddProductStock):
event = ProductStockAdded(
product_id=command.product_id,
quantity=command.quantity
)
self.event_store.append(event)
return True
return False
# Event Store (simplified)
class EventStore:
def __init__(self):
self.events = []
def append(self, event):
self.events.append(event)
# Events that represent state changes
class ProductUpdatedPrice:
def __init__(self, product_id, new_price):
self.product_id = product_id
self.new_price = new_price
class ProductStockAdded:
def __init__(self, product_id, quantity):
self.product_id = product_id
self.quantity = quantity
# Usage (Command Side)
event_store = EventStore()
command_handler = ProductCommandHandler(event_store)
# Dispatch a command
product_id = command_handler.handle(ProductCreated(None, "Super Gadget", 199.99, 50))
command_handler.handle(UpdateProductPrice(product_id, 219.99))
command_handler.handle(AddProductStock(product_id, 25))
print(f"Events after commands: {event_store.events}")
Here, commands are simple data transfer objects. A ProductCommandHandler processes these commands and, crucially, publishes events that represent the state changes. This is typical of an event-sourced write model.
Read Side (Queries):
The read side needs to be optimized for querying. This often means denormalized data, projections, or separate read models.
# Read Model (a simple dictionary for illustration)
product_read_models = {}
# Event Bus/Dispatcher (simplified)
class EventBus:
def __init__(self):
self.handlers = {}
def subscribe(self, event_type, handler):
if event_type not in self.handlers:
self.handlers[event_type] = []
self.handlers[event_type].append(handler)
def publish(self, event):
event_type = type(event)
if event_type in self.handlers:
for handler in self.handlers[event_type]:
handler(event)
# Projection/Event Handler for Read Model
class ProductProjection:
def __init__(self, read_models):
self.read_models = read_models
def handle_product_created(self, event):
self.read_models[event.product_id] = {
"id": event.product_id,
"name": event.name,
"price": event.price,
"stock_count": event.initial_stock
}
def handle_product_updated_price(self, event):
if event.product_id in self.read_models:
self.read_models[event.product_id]["price"] = event.new_price
def handle_product_stock_added(self, event):
if event.product_id in self.read_models:
self.read_models[event.product_id]["stock_count"] += event.quantity
# Query Service
class ProductQueryService:
def __init__(self, read_models):
self.read_models = read_models
def get_product_details(self, product_id):
return self.read_models.get(product_id)
# Wiring everything up
event_bus = EventBus()
product_projection = ProductProjection(product_read_models)
# Subscribe handlers to events
event_bus.subscribe(ProductCreated, product_projection.handle_product_created)
event_bus.subscribe(ProductUpdatedPrice, product_projection.handle_product_updated_price)
event_bus.subscribe(ProductStockAdded, product_projection.handle_product_stock_added)
# Publish events from the event store to the bus
for event in event_store.events:
event_bus.publish(event)
# Usage (Query Side)
query_service = ProductQueryService(product_read_models)
details = query_service.get_product_details(product_id)
print(f"Read model details: {details}")
Now, when a command is handled, it publishes an event. This event is caught by the EventBus, which dispatches it to subscribed handlers (our ProductProjection). The projection updates the product_read_models dictionary, which is then queried by the ProductQueryService.
This separation allows the read model to be highly optimized for specific queries, perhaps using a NoSQL document store or a search index, while the write model can focus on business logic and consistency, potentially using an event store.
The problem arises when the complexity of the CQRS infrastructure itself outweighs the benefits gained from separating read and write concerns. This often happens when:
-
The domain isn’t actually complex enough to warrant separation. If your business logic is simple, and your queries don’t have drastically different performance requirements, you’re just adding layers of indirection for no good reason. The original single-model approach might have been perfectly adequate and far simpler to maintain.
-
You over-engineer the read models. Creating numerous, highly specialized read models (projections) for every conceivable query can lead to a maintenance nightmare. Each read model needs to be updated, synchronized, and accounted for. If a new query requirement emerges, you might need to create a new projection, which means defining new events or adapting existing ones, and writing new handler logic.
-
Eventual consistency becomes a primary concern without a clear need. CQRS, especially with separate databases or services, often implies eventual consistency. If your application requires strong consistency between reads and writes (e.g., an immediate check of stock before a sale can be confirmed), managing this across separate read and write models becomes a significant challenge. You might need complex compensating transactions or polling mechanisms, adding considerable complexity.
-
The "happy path" is simple, but the error handling and debugging become convoluted. Imagine debugging a command that doesn’t result in an expected change in the read model. You have to trace the command through the command handler, to the event store, to the event bus, to the projection handler, and finally to the read model. This multi-stage process makes debugging significantly harder than stepping through a single object’s methods.
-
You introduce CQRS prematurely. Developers sometimes adopt CQRS as a "best practice" without a concrete problem it solves for their current application. They build the event store, the command bus, the read models, and the projections, only to find that the original single model would have handled the current load and complexity just fine. The overhead of the CQRS pattern then becomes a burden.
-
The communication between write and read sides is not well-defined. If the events are not granular enough, or if the projections don’t correctly interpret them, the read models will become stale or inaccurate. This requires careful design of the event schema and robust event handling logic.
The real power of CQRS shines when you have a truly complex domain where the operations to change state are fundamentally different from the operations to retrieve state, and where optimizing each side independently yields significant benefits that outweigh the inherent architectural complexity. Without these conditions, it’s often a case of building a complicated machine to perform a simple task.
The next hurdle you’ll likely face is managing distributed transactions or ensuring strong consistency when your read and write models are deployed as separate services.