CQRS isn’t about separating reads and writes; it’s about having different models for reads and writes.

Imagine a typical CRUD application: you have a Product entity, and you have methods like UpdateProductPrice(productId, newPrice) and GetProductById(productId). Both of these operate on the same Product object, with the same fields, the same validation rules, and the same database table. This is the "single model" approach.

Now, let’s say your UpdateProductPrice operation becomes complex. Maybe it needs to check inventory levels, trigger a notification to sales, and update a historical pricing log. Your GetProductById operation, on the other hand, just needs to fetch a few fields quickly for a product listing page. A single model starts to buckle under the weight of these divergent requirements.

CQRS lets you decouple these. You can have a ProductWriteModel optimized for commands like UpdateProductPrice, which might include richer validation and business logic. Separately, you can have a ProductReadModel (or multiple read models) optimized for queries like GetProductById, perhaps denormalized into a structure that directly maps to your UI needs, making reads lightning fast.

Here’s a simplified look at how this plays out in code.

The "Before" (CRUD):

public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int StockQuantity { get; set; }
}

public class ProductRepository
{
    private readonly Dictionary<Guid, Product> _products = new Dictionary<Guid, Product>();

    public Product GetById(Guid id)
    {
        return _products.GetValueOrDefault(id);
    }

    public void Save(Product product)
    {
        _products[product.Id] = product;
    }
}

public class ProductService
{
    private readonly ProductRepository _repository;

    public ProductService(ProductRepository repository)
    {
        _repository = repository;
    }

    public void UpdateProductPrice(Guid productId, decimal newPrice)
    {
        var product = _repository.GetById(productId);
        if (product == null) throw new Exception("Product not found");

        // Business logic might be here, but can get mixed
        if (newPrice < 0) throw new ArgumentOutOfRangeException(nameof(newPrice), "Price cannot be negative");

        product.Price = newPrice;
        _repository.Save(product);
    }

    public Product GetProductDetails(Guid productId)
    {
        return _repository.GetById(productId);
    }
}

The "After" (CQRS - Incremental Approach):

We start by identifying a part of the system where the read and write models are diverging. Let’s focus on product pricing.

  1. Introduce Command and Command Handler for Writes:

    We’ll create a dedicated command for updating the price and a handler for it. This handler will still use the existing Product entity for now, but it’s the first step towards model separation.

    public class UpdateProductPriceCommand
    {
        public Guid ProductId { get; set; }
        public decimal NewPrice { get; set; }
    }
    
    public class UpdateProductPriceCommandHandler
    {
        private readonly ProductRepository _repository; // Still using the old repo
    
        public UpdateProductPriceCommandHandler(ProductRepository repository)
        {
            _repository = repository;
        }
    
        public void Handle(UpdateProductPriceCommand command)
        {
            var product = _repository.GetById(command.ProductId);
            if (product == null) throw new Exception("Product not found");
    
            // More focused business logic for price update
            if (command.NewPrice < 0) throw new ArgumentOutOfRangeException(nameof(command.NewPrice), "Price cannot be negative");
            if (command.NewPrice > 1000000) throw new ArgumentOutOfRangeException(nameof(command.NewPrice), "Price too high"); // Example additional validation
    
            product.Price = command.NewPrice;
            _repository.Save(product);
        }
    }
    

    Now, ProductService would delegate to UpdateProductPriceCommandHandler for price updates.

  2. Introduce Query and Query Handler for Reads:

    We need a read model optimized for displaying product prices. Let’s call it ProductPriceView.

    public class ProductPriceView
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
    
    public class GetProductPriceQuery
    {
        public Guid ProductId { get; set; }
    }
    
    public class GetProductPriceQueryHandler
    {
        // This would ideally query a separate read-optimized store (e.g., a denormalized table, a document DB)
        // For now, we'll simulate by querying the existing repository and projecting
        private readonly ProductRepository _repository;
    
        public GetProductPriceQueryHandler(ProductRepository repository)
        {
            _repository = repository;
        }
    
        public ProductPriceView Handle(GetProductPriceQuery query)
        {
            var product = _repository.GetById(query.ProductId);
            if (product == null) return null;
    
            return new ProductPriceView
            {
                Id = product.Id,
                Name = product.Name,
                Price = product.Price
            };
        }
    }
    

    The ProductService would now use GetProductPriceQueryHandler for price-related queries.

  3. The Separation:

    The key here is that UpdateProductPriceCommand and GetProductPriceQuery are distinct. The UpdateProductPriceCommandHandler might perform complex domain logic and save to a relational database. The GetProductPriceQueryHandler might query a denormalized view specifically built for fast lookups, perhaps in a document database or a search index.

    The incremental migration means we don’t rip out the old system. We introduce the CQRS pattern for specific bounded contexts or features where the benefits are clear. We might start with one command/query pair, then expand.

    The actual separation of data stores is the next logical step. When UpdateProductPriceCommandHandler saves, it could publish an event (e.g., ProductPriceUpdatedEvent). A separate "projection" service would listen to this event and update a dedicated read store (e.g., a ProductPriceDocument in MongoDB) that GetProductPriceQueryHandler reads from.

    // Example of event publishing and projection
    public class ProductPriceUpdatedEvent
    {
        public Guid ProductId { get; set; }
        public decimal NewPrice { get; set; }
        public string ProductName { get; set; } // Denormalized for read model
    }
    
    // In UpdateProductPriceCommandHandler.Handle:
    // ... after product.Price = command.NewPrice;
    // _repository.Save(product);
    // _eventPublisher.Publish(new ProductPriceUpdatedEvent { ProductId = product.Id, NewPrice = product.Price, ProductName = product.Name });
    
    public class ProductPriceProjectionHandler
    {
        // Injects a repository for the read store
        private readonly IProductPriceReadRepository _readRepository;
    
        public ProductPriceProjectionHandler(IProductPriceReadRepository readRepository)
        {
            _readRepository = readRepository;
        }
    
        public void Handle(ProductPriceUpdatedEvent ev)
        {
            var document = new ProductPriceDocument
            {
                Id = ev.ProductId,
                Name = ev.ProductName,
                Price = ev.NewPrice
            };
            _readRepository.Save(document); // Updates the document database
        }
    }
    
    public class GetProductPriceQueryHandler // Modified to read from read store
    {
        private readonly IProductPriceReadRepository _readRepository;
    
        public GetProductPriceQueryHandler(IProductPriceReadRepository readRepository)
        {
            _readRepository = readRepository;
        }
    
        public ProductPriceView Handle(GetProductPriceQuery query)
        {
            var document = _readRepository.GetById(query.ProductId);
            if (document == null) return null;
    
            return new ProductPriceView
            {
                Id = document.Id,
                Name = document.Name,
                Price = document.Price
            };
        }
    }
    

The true power of CQRS emerges not just from separating read and write models, but from optimizing each model for its specific task. The write model can be a rich domain model with complex business rules, while the read models can be highly denormalized, even derived from multiple sources, to serve specific query needs with minimal latency. This allows the system to evolve independently for read and write workloads, which is crucial for scaling and for accommodating different user experiences or integration points.

The next challenge you’ll face is managing eventual consistency between your write and read models when you introduce separate data stores.

Want structured learning?

Take the full Cqrs course →