Skip to content

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 Order Aggregate. It encapsulates complex business logic, protects invariants, and is ideal for state-changing tasks like CreateOrder and CancelOrder.
  • 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:

  1. Use the IOrderRepository to get a list of Order Aggregates.
  2. For each Order, the repository must also load all OrderItems to ensure the aggregate is complete.
  3. The Application Service then has to loop through this list, possibly access the Customer aggregate to get the name, and then map the data to a DTO for display.
  • Bottleneck Analysis:
    1. Over-fetching: We are loading the entire Order Aggregate with all its detailed OrderItems—a very "heavy" object—just to display a few summary fields. This is a waste of resources.
    2. Complex Queries: Querying on aggregates often leads to complex and inefficient JOIN queries for reading.
    3. 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.

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).
  1. 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 a CommandHandler. This handler uses the Repository to get the Aggregate, calls the business method (order.Cancel()), and saves it. This path typically returns void or a simple success/failure result.
  2. 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 a QueryHandler. 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 ✨

  1. Maximum Read Performance: Read queries are now extremely fast. They no longer have the overhead of creating complex domain objects.
  2. 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.
  3. Clarity: The code becomes very clear. A Command changes data. A Query retrieves data.

Conclusion:

  • CQRS is an architectural pattern that perfectly complements DDD.
  • 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.