CQRS (Command Query Responsibility Segregation)
Mind Map Summary
- CQRS (Command Query Responsibility Segregation)
- Core Principle: A method that changes state should be separate from a method that returns data.
- The Two Sides:
- Command Side (The “C”)
- Purpose: To handle state changes (
CREATE
,UPDATE
,DELETE
). - Objects: Commands (e.g.,
CreateProductCommand
). They represent intent. - Return Value: Typically
void
orTask
. They should not return data. - Model: Often a rich, normalized domain model with business logic.
- Purpose: To handle state changes (
- Query Side (The “Q”)
- Purpose: To handle data retrieval (
READ
). - Objects: Queries (e.g.,
GetProductByIdQuery
). - Return Value: A Data Transfer Object (DTO). Queries must never modify state.
- Model: Can be a completely different, highly denormalized model optimized for reads.
- Purpose: To handle data retrieval (
- Command Side (The “C”)
- Benefits:
- Scalability: You can scale the read and write databases independently.
- Performance: Read models can be highly optimized for specific queries, avoiding complex
JOIN
s. - Flexibility: Use a relational database for the write side and a document database for the read side.
- Simplicity: Each model (command or query) is simpler because it only has one job.
- Drawbacks:
- Complexity: More moving parts than a simple CRUD model.
- Eventual Consistency: If using separate read/write databases, the read model can be slightly stale. Data from the write side must be synchronized to the read side.
Core Concepts
1. The Problem with CRUD
In a traditional CRUD (Create, Read, Update, Delete) architecture, a single, all-purpose data model is used for both reading and writing. For simple applications, this is fine. But for complex systems, it leads to problems. The model becomes a jack-of-all-trades, master of none. The requirements for writing data (normalization, validation, complex business rules) are often very different from the requirements for reading data (denormalization for performance, custom shapes for different UI views). Trying to make one model do both things well leads to a bloated, complicated object.
2. Segregation of Responsibility
CQRS solves this by splitting the model in two.
-
The Command Model: This is your transactional, domain-rich model. It handles all the business logic and validation for changing the state of your application. When a
CreateProductCommand
comes in, the command handler might load theProduct
aggregate, execute business rules, and save the changes. Its only job is to enforce consistency. -
The Query Model: This model is completely separate and is only concerned with providing data to the UI as efficiently as possible. It doesn’t contain any business logic. A query handler might execute a raw, hand-optimized SQL query against a denormalized “read table” or a document database and map the result directly to a DTO. It is optimized purely for speed.
3. Eventual Consistency
In simple CQRS implementations, both the command and query handlers might talk to the same database. In this case, the data is always consistent. However, in more advanced patterns, you might have a relational database for your write model and a separate document database (like Elasticsearch or MongoDB) for your read model. When the command side makes a change, it publishes a domain event. A separate process listens for this event and updates the read database. This means there is a small delay before the change is visible on the query side. This is known as eventual consistency and is a trade-off you make for the immense performance and scalability benefits.
Practice Exercise
Implement a simple in-memory CQRS pattern. Create a CreateProductCommand
and a corresponding CreateProductCommandHandler
. Create a GetProductByIdQuery
and a corresponding GetProductByIdQueryHandler
. Create a simple “mediator” to dispatch commands and queries to their handlers.
Answer
Code Example
1. The Data Store and Models
// Simple in-memory data store
public static class ProductStore
{
public static List<Product> Products = new();
}
public class Product { public int Id { get; set; } public string Name { get; set; } }
public class ProductDto { public int Id { get; set; } public string Name { get; set; } }
2. Define Commands and Queries
// Command: Represents the intent to change state
public class CreateProductCommand { public int Id { get; set; } public string Name { get; set; } }
// Query: Represents the intent to get data
public class GetProductByIdQuery { public int Id { get; set; } }
3. Create the Handlers
// Command Handler: Changes state, returns nothing (void/Task)
public class CreateProductCommandHandler
{
public void Handle(CreateProductCommand command)
{
var product = new Product { Id = command.Id, Name = command.Name };
ProductStore.Products.Add(product);
Console.WriteLine($"COMMAND: Added product '{product.Name}'");
}
}
// Query Handler: Returns data, does not change state
public class GetProductByIdQueryHandler
{
public ProductDto Handle(GetProductByIdQuery query)
{
var product = ProductStore.Products.FirstOrDefault(p => p.Id == query.Id);
if (product == null) return null;
Console.WriteLine($"QUERY: Returning product '{product.Name}'");
return new ProductDto { Id = product.Id, Name = product.Name };
}
}
4. The Simple Mediator
This class knows how to find and execute the correct handler for a given request.
public class Mediator
{
// In a real app, this would be done with DI and reflection
private readonly IServiceProvider _serviceProvider;
public Mediator(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; }
public void Send(CreateProductCommand command)
{
var handler = (CreateProductCommandHandler)_serviceProvider.GetService(typeof(CreateProductCommandHandler));
handler.Handle(command);
}
public ProductDto Send(GetProductByIdQuery query)
{
var handler = (GetProductByIdQueryHandler)_serviceProvider.GetService(typeof(GetProductByIdQueryHandler));
return handler.Handle(query);
}
}
5. Client Code
// Setup DI container (for the mediator)
var services = new ServiceCollection();
services.AddTransient<CreateProductCommandHandler>();
services.AddTransient<GetProductByIdQueryHandler>();
services.AddTransient<Mediator>();
var serviceProvider = services.BuildServiceProvider();
var mediator = serviceProvider.GetService<Mediator>();
// 1. Send a command to change state
var createCommand = new CreateProductCommand { Id = 1, Name = "Laptop" };
mediator.Send(createCommand);
// 2. Send a query to read state
var query = new GetProductByIdQuery { Id = 1 };
var productDto = mediator.Send(query);
Console.WriteLine($"DTO received: {productDto.Name}");
Explanation
This example clearly separates the concerns:
- The
CreateProductCommand
and its handler are solely responsible for writing data. The handler returnsvoid
. - The
GetProductByIdQuery
and its handler are solely responsible for reading data. The handler returns aProductDto
and has no side effects. - The client code doesn’t know about the handlers. It communicates its intent through the
Mediator
by sending either a command or a query object. This decoupling is a key benefit of using a mediator with CQRS.
(Note: A library like MediatR automates the process of finding and dispatching to handlers, removing the need to write a manual Mediator
class.)