Chapter 5: Separating "Writes" and "Reads" - Introduction to CQRS
Of course. After establishing the tactical patterns to build a robust domain model (Entities, Value Objects, Aggregates, Repositories), it's time to move on to the second part of our series: CQRS.
The Scenario 📝
- System: Our e-commerce application.
- The "Write" Side: We have built a very good
OrderAggregate. It encapsulates complex business logic, protects invariants, and is ideal for state-changing tasks likeCreateOrderandCancelOrder. - The "Read" Side: A new requirement has emerged: build a dashboard for administrators that displays a list of orders with summary information: Order ID, customer name, total price, and the number of products.
The Problem: Using a Single Model for Both Writes and Reads
If we only use our existing DDD model, the query to get data for the dashboard would look like this:
- Use the
IOrderRepositoryto get a list ofOrderAggregates. - For each
Order, the repository must also load allOrderItemsto ensure the aggregate is complete. - The Application Service then has to loop through this list, possibly access the
Customeraggregate to get the name, and then map the data to a DTO for display.
- Bottleneck Analysis:
- Over-fetching: We are loading the entire
OrderAggregate with all its detailedOrderItems—a very "heavy" object—just to display a few summary fields. This is a waste of resources. - Complex Queries: Querying on aggregates often leads to complex and inefficient
JOINqueries for reading. - Conflicting Needs: The model for writing needs to be normalized and tightly encapsulated to ensure integrity. The model for reading needs to be "flat" and denormalized to fetch data as quickly as possible. Using a single model is a poor compromise.
- Over-fetching: We are loading the entire
The Solution: Apply CQRS ✅
- The Logic: CQRS (Command Query Responsibility Segregation) is an architectural pattern that completely separates the models and processing paths for changing state (Commands) and querying data (Queries).
The Command Path (Write Side):
- Purpose: To change the system's state.
- Components: Uses the entire DDD model we've built: Aggregates, Repositories, etc.
- Flow: A
Command(e.g.,CancelOrderCommand) is sent to aCommandHandler. This handler uses the Repository to get the Aggregate, calls the business method (order.Cancel()), and saves it. This path typically returnsvoidor a simple success/failure result.
The Query Path (Read Side):
- Purpose: To fetch data for display.
- Components: Completely bypasses the Domain Model (Aggregates, Repositories). It queries a "read model" directly.
- Read Model: This could be the same production database, but queried through a different, lightweight channel (e.g., using Dapper or EF Core with
.AsNoTracking()and.Select()to project directly to a DTO). - Flow: A
Query(e.g.,GetOrderDashboardQuery) is sent to aQueryHandler. This handler executes a highly optimized SQL (or LINQ) query for reading, fetches exactly the data needed, and returns a "flat" DTO.
Example Code for the Query Path:
csharp// Query and DTO public record GetOrderDashboardQuery(); public record OrderDashboardDto(int OrderId, string CustomerName, decimal TotalPrice, int ItemCount); // Query Handler public class GetOrderDashboardQueryHandler { private readonly IDbConnectionFactory _dbFactory; // Could be using Dapper public async Task<List<OrderDashboardDto>> Handle(GetOrderDashboardQuery query) { using var connection = _dbFactory.CreateConnection(); // This SQL is highly optimized for this specific read case var sql = @" SELECT o.Id AS OrderId, c.Name AS CustomerName, o.TotalPrice, COUNT(oi.Id) AS ItemCount FROM Orders o JOIN Customers c ON o.CustomerId = c.Id JOIN OrderItems oi ON o.Id = oi.OrderId GROUP BY o.Id, c.Name, o.TotalPrice ORDER BY o.OrderDate DESC"; // Query directly, bypassing the Aggregate return (await connection.QueryAsync<OrderDashboardDto>(sql)).ToList(); } }
Analysis of the Result ✨
- Maximum Read Performance: Read queries are now extremely fast. They no longer have the overhead of creating complex domain objects.
- Separation and Flexibility: You can optimize the schema for reading (e.g., create a materialized view) without affecting the normalized schema for writing. You can scale these two paths independently.
- Clarity: The code becomes very clear. A
Commandchanges data. AQueryretrieves data.
Conclusion:
CQRSis an architectural pattern that perfectly complementsDDD.- It acknowledges that the model you need to change data is often very different from the model you need to display data.
- By separating these two responsibilities, you can build systems that both ensure complex business integrity and deliver extremely high read performance.