The most surprising truth about CQRS is that it often simplifies complex domains by separating concerns that are implicitly, and often confusingly, tangled in CRUD.

Imagine a typical e-commerce order system. With CRUD, you have one Order model and a set of operations: CreateOrder, UpdateOrder, GetOrder, DeleteOrder. But what does UpdateOrder even mean? Does it mean updating the shipping address? The payment method? The line items? In a complex system, these are radically different operations with different data requirements, validation rules, and even different user roles authorized to perform them.

CQRS untangles this. It proposes separate models for writing (Commands) and reading (Queries). Instead of a monolithic Order model, you might have a PlaceOrderCommand and an UpdateShippingAddressCommand. These commands are intent-based – they represent an action a user wants to perform. They don’t hold the current state of the order; they just carry the necessary data for the action.

On the read side, you’d have specialized queries like GetOrderForCustomerQuery (which might fetch only the order status and total), GetOrderForShippingQuery (which might fetch the shipping address and items), or GetOrderForReportingQuery (which might fetch historical data and sales figures). These queries are optimized for their specific use cases, often reading from denormalized "read models" that are built and maintained by the write side.

Here’s a simplified look at how this might manifest in code, focusing on the command side first.

// A Command representing an intent to place an order
public class PlaceOrderCommand
{
    public Guid CustomerId { get; set; }
    public List<OrderItemDto> Items { get; set; }
    public ShippingAddressDto ShippingAddress { get; set; }
    public PaymentDetailsDto PaymentDetails { get; set; }
}

// A Command Handler that processes the command
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEventPublisher _eventPublisher; // For domain events

    public PlaceOrderCommandHandler(IOrderRepository orderRepository, IEventPublisher eventPublisher)
    {
        _orderRepository = orderRepository;
        _eventPublisher = eventPublisher;
    }

    public async Task Handle(PlaceOrderCommand command)
    {
        // Logic to validate the command, potentially check inventory, etc.
        var order = new Order(command.CustomerId, command.Items, command.ShippingAddress, command.PaymentDetails);

        await _orderRepository.AddAsync(order);
        await _orderRepository.SaveChangesAsync();

        // Publish domain events (e.g., OrderPlaced) which can trigger read model updates
        await _eventPublisher.PublishAsync(new OrderPlacedEvent(order.Id, order.CustomerId));
    }
}

Now, for the read side. This often involves a separate data store or a denormalized view.

// A Query to get order details for a customer
public class GetOrderForCustomerQuery
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
}

// A Query Handler optimized for reading
public class GetOrderForCustomerQueryHandler : IQueryHandler<GetOrderForCustomerQuery, OrderSummaryViewModel>
{
    private readonly IOrderReadModelRepository _readModelRepository;

    public GetOrderForCustomerQueryHandler(IOrderReadModelRepository readModelRepository)
    {
        _readModelRepository = readModelRepository;
    }

    public async Task<OrderSummaryViewModel> Handle(GetOrderForCustomerQuery query)
    {
        // Reads directly from a denormalized view, optimized for display
        return await _readModelRepository.GetOrderSummaryByIdAsync(query.OrderId, query.CustomerId);
    }
}

// The ViewModel returned by the query handler
public class OrderSummaryViewModel
{
    public Guid Id { get; set; }
    public DateTime OrderDate { get; set; }
    public string Status { get; set; }
    public decimal TotalAmount { get; set; }
}

The problem CQRS solves is the inherent impedance mismatch between the transactional nature of writing data and the often-complex, varied needs of reading data. In CRUD, you often end up with a single data model that’s a compromise – too much data for some reads, not enough for others, and complex update logic that tries to handle every possible scenario. CQRS allows you to optimize each side independently. The write side can be optimized for consistency and business logic, often using domain-driven design principles and potentially event sourcing. The read side can be optimized for performance and specific query needs, using denormalized views, different database technologies (like a document database for read models), and tailored projections.

The core mechanism for bridging the two sides is often through domain events. When a command is successfully processed on the write side and results in a state change (e.g., an OrderPlaced event), this event is published. Separate "projection" services or handlers subscribe to these events and update the read models. This ensures that the read models are eventually consistent with the write side.

The one thing most people don’t intuitively grasp is how powerfully CQRS can enable different persistence strategies for read and write. Your write side might use a relational database optimized for ACID transactions and complex relationships, while your read models could live in a highly performant NoSQL document database, a search index like Elasticsearch, or even in-memory caches, all populated asynchronously from the write-side events. This allows you to pick the best tool for each specific job, rather than being constrained by a single, all-purpose data store.

The next logical step after mastering CQRS is often exploring event sourcing, where the write side doesn’t store current state but rather a sequence of immutable events that represent state changes over time.

Want structured learning?

Take the full Cqrs course →