CQRS isn’t about separating read and write models; it’s about separating the commands and queries themselves, and the systems that handle them.

Let’s see this in action. Imagine a simple e-commerce system.

Commands:

  • CreateOrderCommand(customerId, items)
  • CancelOrderCommand(orderId)
  • AddItemToOrderCommand(orderId, item)

These commands are typically handled by a command side handler, which validates the command, perhaps performs some business logic, and then persists a new state or modifies an existing one. This often involves an Order aggregate root.

Queries:

  • GetOrderByIdQuery(orderId)
  • GetOrdersByCustomerIdQuery(customerId)
  • GetRecentOrdersQuery()

These queries are handled by a query side handler, which retrieves data from a read-optimized data store. This data store might be populated by subscribing to events emitted by the command side.

Here’s a simplified view of the command side processing an order creation:

// Command Handler
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
    private readonly IEventStore _eventStore; // Stores domain events

    public CreateOrderCommandHandler(IEventStore eventStore)
    {
        _eventStore = eventStore;
    }

    public async Task Handle(CreateOrderCommand command)
    {
        var order = new Order(command.CustomerId); // New Order aggregate
        foreach (var item in command.Items)
        {
            order.AddItem(item.ProductId, item.Quantity);
        }
        await _eventStore.AppendEventsAsync(order.Id, order.GetUncommittedEvents());
    }
}

// Order Aggregate Root
public class Order : AggregateRoot
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    private List<OrderItem> _items = new List<OrderItem>();

    public Order(Guid customerId)
    {
        ApplyChange(new OrderCreatedEvent(Guid.NewGuid(), customerId));
    }

    // ... other methods like AddItem ...

    private void ApplyChange(IDomainEvent eventItem)
    {
        ((dynamic)this).Apply((dynamic)eventItem);
        _uncommittedEvents.Add(eventItem);
    }

    // Event handlers within the aggregate
    public void Apply(OrderCreatedEvent ev)
    {
        Id = ev.OrderId;
        CustomerId = ev.CustomerId;
    }
}

And here’s a query side example, perhaps using a different database optimized for reads:

// Query Handler
public class GetOrderByIdQueryHandler : IQueryHandler<GetOrderByIdQuery, OrderReadModel>
{
    private readonly IDocumentStore _documentStore; // e.g., MongoDB, Elasticsearch

    public GetOrderByIdQueryHandler(IDocumentStore documentStore)
    {
        _documentStore = documentStore;
    }

    public async Task<OrderReadModel> Handle(GetOrderByIdQuery query)
    {
        return await _documentStore.GetByIdAsync<OrderReadModel>(query.OrderId.ToString());
    }
}

// Read Model
public class OrderReadModel
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public List<OrderItemReadModel> Items { get; set; }
    public decimal TotalPrice { get; set; }
    public string Status { get; set; }
}

The problem CQRS solves is the impedance mismatch between how you want to change data (rich, transactional command side) and how you want to view data (fast, denormalized query side). It allows you to optimize each side independently. The command side can use event sourcing or transactional databases focused on consistency, while the query side can use relational databases, document stores, or even in-memory caches optimized for retrieval performance.

Unit Testing:

Focus on individual components in isolation.

  • Command Handlers: Test that a command handler correctly validates input, invokes the correct aggregate methods, and produces the expected domain events (or commands for subsequent steps).

    [Fact]
    public async Task CreateOrderCommandHandler_ValidCommand_CreatesOrderAndPublishesEvent()
    {
        var mockEventStore = new Mock<IEventStore>();
        var command = new CreateOrderCommand(Guid.NewGuid(), new List<OrderItemDto> { new OrderItemDto("prod-1", 2) });
        var handler = new CreateOrderCommandHandler(mockEventStore.Object);
    
        await handler.Handle(command);
    
        // Assert that AppendEventsAsync was called with the correct event(s)
        mockEventStore.Verify(es => es.AppendEventsAsync(It.IsAny<Guid>(), It.Is<IEnumerable<IDomainEvent>>(events =>
            events.Any(e => e is OrderCreatedEvent && ((OrderCreatedEvent)e).CustomerId == command.CustomerId)
        )), Times.Once);
    }
    
  • Aggregates: Test the state transitions and business rules within an aggregate.

    [Fact]
    public void Order_AddItem_AddsItemToOrder()
    {
        var order = new Order(Guid.NewGuid()); // Assume OrderCreatedEvent is applied internally
        var productId = "prod-1";
        var quantity = 2;
    
        // Simulate applying the initial event
        var orderCreatedEvent = new OrderCreatedEvent(Guid.NewGuid(), Guid.NewGuid());
        ((dynamic)order).Apply(orderCreatedEvent); // Directly apply the event for state setup
    
        order.AddItem(productId, quantity); // Call the method under test
    
        // Assert that the item was added correctly
        var addedItemEvent = order.GetUncommittedEvents().OfType<OrderItemAddedEvent>().FirstOrDefault();
        Assert.NotNull(addedItemEvent);
        Assert.Equal(productId, addedItemEvent.ProductId);
        Assert.Equal(quantity, addedItemEvent.Quantity);
    }
    
  • Query Handlers: Test that a query handler correctly fetches data from its read model store.

    [Fact]
    public async Task GetOrderByIdQueryHandler_OrderExists_ReturnsOrderReadModel()
    {
        var orderId = Guid.NewGuid();
        var expectedReadModel = new OrderReadModel { OrderId = orderId, CustomerId = Guid.NewGuid() };
        var mockDocumentStore = new Mock<IDocumentStore>();
        mockDocumentStore.Setup(ds => ds.GetByIdAsync<OrderReadModel>(orderId.ToString())).ReturnsAsync(expectedReadModel);
    
        var query = new GetOrderByIdQuery(orderId);
        var handler = new GetOrderByIdQueryHandler(mockDocumentStore.Object);
    
        var result = await handler.Handle(query);
    
        Assert.NotNull(result);
        Assert.Equal(orderId, result.OrderId);
    }
    

Integration Testing:

Focus on the interaction between components, especially the command side producing events and the mechanism that publishes them, and the query side consuming them.

  • Event Publishing and Subscription: Test that when a command is processed, the resulting domain events are correctly published to an event bus or message queue, and that subscribers (e.g., read model projectors) receive these events and update their data stores.

    You’d typically spin up a test instance of your message broker (like RabbitMQ or Kafka) or use an in-memory implementation for tests.

    // Test setup using an in-memory event bus
    var eventBus = new InMemoryEventBus();
    var commandRepository = new EventSourcedRepository(eventBus); // Command side
    var readModelProjector = new OrderProjector(new InMemoryDocumentStore()); // Query side
    eventBus.Subscribe<OrderCreatedEvent>(readModelProjector.Handle);
    
    var commandHandler = new CreateOrderCommandHandler(commandRepository);
    var command = new CreateOrderCommand(Guid.NewGuid(), new List<OrderItemDto> { new OrderItemDto("prod-1", 1) });
    
    // Act: Process the command
    await commandHandler.Handle(command);
    
    // Assert: Check if the read model was updated (after event processing)
    // This often requires a small delay or a mechanism to wait for asynchronous processing.
    await Task.Delay(100); // Simulate async event handling
    
    var readModel = await readModelProjector.DocumentStore.GetByIdAsync<OrderReadModel>(/* order id from command */);
    Assert.NotNull(readModel);
    Assert.Equal(command.CustomerId, readModel.CustomerId);
    
  • Command to Query Data Flow: Test the end-to-end flow from a command being issued to the corresponding data appearing in the read model, including any intermediate steps like event serialization, transport, and projection.

Acceptance Testing:

Focus on user scenarios and business requirements. These tests should verify that the system behaves as expected from an external perspective, treating CQRS components as black boxes.

  • End-to-End User Flows: Simulate a user interacting with the system through its API or UI.

    For example, creating an order and then immediately querying for it.

    Scenario: User creates an order and views it
      Given a customer exists with ID "cust-123"
      When the customer creates an order with items "prod-A" (2) and "prod-B" (1)
      Then the order should be successfully created with ID "order-XYZ"
      And when the customer views order "order-XYZ"
      Then the order details should show items "prod-A" (2) and "prod-B" (1)
    

    The implementation of these steps would involve making HTTP requests to your command API to create the order and then to your query API to retrieve it, asserting the responses.

The most subtle aspect of CQRS testing is often the eventual consistency between the write and read models. While you test the command side for immediate consistency, the query side might lag. Your integration and acceptance tests need to account for this potential delay, either by explicitly waiting for events to be processed or by structuring tests to handle transient inconsistencies. This means your Assert statements in integration tests might need to poll the read model or use callbacks to know when the projection is complete, rather than just checking immediately after the command handler returns.

The next challenge you’ll face is managing distributed transactions and sagas when a single business operation spans multiple services or aggregates.

Want structured learning?

Take the full Cqrs course →