← Back to Blog

CQRS and Mediator Pattern: Separating Reads and Writes

If you've ever struggled with a controller that's responsible for both fetching data and processing business logic, you're not alone. Most applications start with simple CRUD operations, but as they grow, that single responsibility principle starts to bend under the weight of complex requirements. CQRS and the Mediator pattern offer a elegant solution that separates these concerns while making your code more maintainable and scalable.

I've worked on several projects where this architectural shift transformed unwieldy codebases into something beautiful. One particular e-commerce platform I helped redesign went from having controllers with hundreds of lines to clean, focused handlers that were easy to test and maintain. The difference was night and day.

In this guide, we'll explore how CQRS (Command Query Responsibility Segregation) and the Mediator pattern work together to create applications that are easier to understand, test, and scale. We'll use MediatR, the most popular mediator implementation for .NET, and build a complete example that demonstrates these patterns in action.

The Problem with Traditional Architectures

Let's start by understanding why traditional approaches can become problematic. Imagine a typical e-commerce application with an order controller. It handles everything: creating orders, updating them, fetching order details, and generating reports.

// Traditional approach - everything in one place
public class OrderController : ControllerBase
{
    private readonly IOrderService _orderService;

    [HttpGet("{id}")]
    public async Task> GetOrder(int id)
    {
        return await _orderService.GetOrderAsync(id);
    }

    [HttpPost]
    public async Task> CreateOrder(CreateOrderRequest request)
    {
        return await _orderService.CreateOrderAsync(request);
    }

    [HttpPut("{id}")]
    public async Task UpdateOrder(int id, UpdateOrderRequest request)
    {
        await _orderService.UpdateOrderAsync(id, request);
        return NoContent();
    }

    [HttpGet("customer/{customerId}")]
    public async Task>> GetCustomerOrders(string customerId)
    {
        return await _orderService.GetCustomerOrdersAsync(customerId);
    }
}

This approach seems straightforward at first, but it creates several problems. The controller becomes a dumping ground for all order-related operations. The service layer grows to handle both complex business logic and simple data retrieval. Testing becomes difficult because each method might have different dependencies and side effects.

More importantly, this approach doesn't reflect how real applications actually work. Reading data and writing data have fundamentally different requirements, performance characteristics, and scalability needs.

Traditional CRUD approaches mix concerns that are better kept separate. CQRS recognizes that reading and writing are different operations with different needs.

Understanding CQRS: Command Query Responsibility Segregation

CQRS is based on a simple but powerful idea: separate your read operations from your write operations. Commands change state, queries read state. That's it.

The most important thing to understand about CQRS is that it's not about having separate databases or even separate models-though those can be benefits. It's about recognizing that the way you write data is different from how you read it.

// Commands - change state
public class CreateOrderCommand : IRequest
{
    public string CustomerId { get; set; }
    public List Items { get; set; }
}

public class UpdateOrderCommand : IRequest
{
    public int OrderId { get; set; }
    public List Items { get; set; }
}

// Queries - read state
public class GetOrderQuery : IRequest
{
    public int OrderId { get; set; }
}

public class GetCustomerOrdersQuery : IRequest>
{
    public string CustomerId { get; set; }
    public DateTime? FromDate { get; set; }
}

Notice how commands and queries are completely separate. Commands don't return data (except maybe an ID), and queries don't modify state. This separation makes your intentions clear and your code easier to test.

In my experience, this separation alone makes CQRS worthwhile. When I look at a method, I immediately know whether it's reading data or changing state. No more wondering if that "GetUser" method is actually updating the user's last login time.

The Mediator Pattern: Decoupling with Style

While CQRS separates commands from queries, the Mediator pattern provides the infrastructure to handle them without tight coupling. Instead of having your controllers know about all the different handlers, they talk to a mediator that routes requests to the appropriate handlers.

// The mediator interface
public interface IMediator
{
    Task Send(IRequest request);
    Task Publish(TNotification notification)
        where TNotification : INotification;
}

// Clean controller - no direct dependencies on handlers
public class OrderController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrderController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task> CreateOrder(CreateOrderRequest request)
    {
        var command = new CreateOrderCommand
        {
            CustomerId = request.CustomerId,
            Items = request.Items
        };

        var orderId = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetOrder), new { id = orderId }, orderId);
    }

    [HttpGet("{id}")]
    public async Task> GetOrder(int id)
    {
        var query = new GetOrderQuery { OrderId = id };
        var order = await _mediator.Send(query);

        if (order == null)
            return NotFound();

        return Ok(order);
    }
}

The controller is now focused solely on HTTP concerns-mapping requests, handling responses, and HTTP status codes. The business logic lives in handlers that the controller doesn't need to know about.

This decoupling is incredibly powerful. I can add new handlers, modify existing ones, or even change the entire implementation without touching the controller. The mediator handles the routing automatically.

Implementing Handlers: Where the Work Gets Done

Handlers are where your business logic lives. Each command and query has its own handler, keeping concerns nicely separated.

// Command handler - focused on business logic
public class CreateOrderCommandHandler : IRequestHandler
{
    public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        // Validate business rules
        if (!request.Items.Any())
            throw new ValidationException("Order must have at least one item");

        // Create the order
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items.Select(MapToOrderItem).ToList(),
            CreatedAt = DateTime.UtcNow,
            Status = OrderStatus.Pending
        };

        var orderId = await _repository.AddAsync(order);
        await _mediator.Publish(new OrderCreatedEvent(orderId, request.CustomerId));
        return orderId;
    }
}

// Query handler - focused on data retrieval
public class GetOrderQueryHandler : IRequestHandler
{
    public async Task Handle(GetOrderQuery request, CancellationToken cancellationToken)
    {
        var order = await _readRepository.GetByIdAsync(request.OrderId);
        return order == null ? null : MapToOrderDto(order);
    }
}

Each handler has a single responsibility and depends only on what it needs. The command handler validates business rules and saves data, while the query handler focuses on efficient data retrieval. This separation makes testing much easier-I can test business logic without worrying about data access, and vice versa.

Domain Events: The Glue That Binds

Domain events are one of my favorite parts of CQRS. They allow different parts of your system to react to changes without tight coupling. When an order is created, multiple things might need to happen: send a confirmation email, update inventory, create a shipping record.

// Domain event
public class OrderCreatedEvent : INotification
{
    public int OrderId { get; }
    public string CustomerId { get; }
    public decimal OrderTotal { get; }

    public OrderCreatedEvent(int orderId, string customerId, decimal orderTotal)
    {
        OrderId = orderId;
        CustomerId = customerId;
        OrderTotal = orderTotal;
    }
}

// Event handler - sends confirmation email
public class OrderConfirmationEmailHandler : INotificationHandler
{
    private readonly IEmailService _emailService;

    public OrderConfirmationEmailHandler(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public async Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        await _emailService.SendOrderConfirmationAsync(
            notification.OrderId,
            notification.CustomerId);
    }
}

// Event handler - updates inventory
public class OrderInventoryHandler : INotificationHandler
{
    private readonly IInventoryService _inventoryService;

    public OrderInventoryHandler(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public async Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        // Reserve items from inventory
        await _inventoryService.ReserveOrderItemsAsync(notification.OrderId);
    }
}

Each event handler focuses on one concern. If I need to add a new reaction to order creation-like updating analytics-I just create a new handler. The original command handler doesn't change.

This approach follows the Open/Closed Principle beautifully. Your system is open for extension but closed for modification.

Use domain events to decouple side effects from your core business logic. Each event can have multiple handlers, each focused on a specific concern.

Read Models and Projections: Optimizing for Queries

One of the most powerful aspects of CQRS is the ability to optimize read models independently of write models. Your write model might be rich with business logic and complex relationships, but your read models can be simple, denormalized views optimized for specific queries.

// Write model - rich domain model
public class Order
{
    public int Id { get; set; }
    public Customer Customer { get; set; }
    public List Items { get; set; }
    public Address ShippingAddress { get; set; }
    public PaymentInfo Payment { get; set; }
    public OrderStatus Status { get; set; }

    // Business logic methods
    public void AddItem(Product product, int quantity)
    {
        // Complex business rules here
    }

    public decimal CalculateTotal()
    {
        // Pricing logic, discounts, taxes
    }
}

// Read model - optimized for display
public class OrderSummaryDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public int ItemCount { get; set; }
}

The write model has all the complexity of business rules and relationships. The read model is a simple DTO optimized for displaying order summaries. This separation allows you to change one without affecting the other.

Projections maintain these read models by listening to domain events and updating the read data accordingly.

// Projection handler - maintains read model
public class OrderSummaryProjectionHandler : INotificationHandler
{
    private readonly IOrderSummaryRepository _repository;

    public OrderSummaryProjectionHandler(IOrderSummaryRepository repository)
    {
        _repository = repository;
    }

    public async Task Handle(OrderCreatedEvent notification, CancellationToken cancellationToken)
    {
        var summary = new OrderSummaryDto
        {
            Id = notification.OrderId,
            CustomerName = await GetCustomerNameAsync(notification.CustomerId),
            Total = notification.OrderTotal,
            Status = "Pending",
            CreatedAt = DateTime.UtcNow,
            ItemCount = await GetOrderItemCountAsync(notification.OrderId)
        };

        await _repository.AddAsync(summary);
    }
}

Projections can be updated synchronously (in the same transaction as the command) or asynchronously for better performance. The choice depends on your consistency requirements.

Cross-Cutting Concerns with Pipeline Behaviors

MediatR's pipeline behaviors allow you to add cross-cutting concerns like validation, logging, and transactions without cluttering your handlers.

// Validation behavior
public class ValidationBehavior : IPipelineBehavior
{
    public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
    {
        var context = new ValidationContext(request);
        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults.SelectMany(result => result.Errors).Where(error => error != null).ToList();
        if (failures.Any()) throw new ValidationException(failures);

        return await next();
    }
}

// Logging behavior
public class LoggingBehavior : IPipelineBehavior
{
    public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
    {
        _logger.LogInformation("Handling {RequestType}", typeof(TRequest).Name);
        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();
        _logger.LogInformation("Handled {RequestType} in {ElapsedMilliseconds}ms", typeof(TRequest).Name, stopwatch.ElapsedMilliseconds);
        return response;
    }
}

Pipeline behaviors are applied automatically to all requests. I can add validation, logging, caching, or any other cross-cutting concern without modifying individual handlers. This keeps my business logic clean and focused.

Testing CQRS Applications: A Developer's Dream

One of the biggest benefits of CQRS is how testable it makes your code. Each handler is isolated and has clear dependencies, making unit testing straightforward.

[Test]
public async Task CreateOrderCommand_ShouldCreateOrder_WhenValidRequest()
{
    // Arrange
    var repository = new Mock();
    var mediator = new Mock();

    repository.Setup(r => r.AddAsync(It.IsAny()))
             .ReturnsAsync(123);

    var handler = new CreateOrderCommandHandler(repository.Object, mediator.Object);
    var command = new CreateOrderCommand
    {
        CustomerId = "customer-1",
        Items = new List
        {
            new OrderItemDto { ProductId = 1, Quantity = 2, UnitPrice = 10.99m }
        }
    };

    // Act
    var result = await handler.Handle(command, CancellationToken.None);

    // Assert
    result.Should().Be(123);
    repository.Verify(r => r.AddAsync(It.IsAny()), Times.Once);
    mediator.Verify(m => m.Publish(It.IsAny(), It.IsAny()), Times.Once);
}

[Test]
public async Task GetOrderQuery_ShouldReturnOrderDto_WhenOrderExists()
{
    // Arrange
    var repository = new Mock();
    var order = new OrderReadModel
    {
        Id = 123,
        CustomerId = "customer-1",
        CustomerName = "John Doe",
        Total = 21.98m,
        Status = "Pending"
    };

    repository.Setup(r => r.GetByIdAsync(123))
             .ReturnsAsync(order);

    var handler = new GetOrderQueryHandler(repository.Object);
    var query = new GetOrderQuery { OrderId = 123 };

    // Act
    var result = await handler.Handle(query, CancellationToken.None);

    // Assert
    result.Should().NotBeNull();
    result.Id.Should().Be(123);
    result.CustomerName.Should().Be("John Doe");
}

Each test is focused and fast. I can test business logic without database calls, and I can test data access without business logic. This level of isolation makes refactoring much safer and debugging much easier.

Eventual Consistency: The Trade-off

CQRS introduces eventual consistency between your read and write models. When you create an order, there's a delay before it appears in read queries. This is usually fine, but you need to handle it appropriately.

// Handling eventual consistency in API responses
[HttpGet("{id}")]
public async Task> GetOrder(int id)
{
    var order = await _mediator.Send(new GetOrderQuery { OrderId = id });

    if (order == null)
    {
        // Check if order exists in write model
        var exists = await _mediator.Send(new OrderExistsQuery { OrderId = id });
        if (exists)
        {
            // Order exists but read model isn't updated yet
            return StatusCode(202, new
            {
                Message = "Order is being processed",
                RetryAfter = 2
            });
        }

        return NotFound();
    }

    return Ok(order);
}

// Client-side handling
public async Task GetOrderWithRetryAsync(int orderId)
{
    const int maxRetries = 5;
    const int delayMs = 1000;

    for (int i = 0; i < maxRetries; i++)
    {
        var response = await _httpClient.GetAsync($"/api/orders/{orderId}");

        if (response.IsSuccessStatusCode)
        {
            return await response.Content.ReadFromJsonAsync();
        }

        if (response.StatusCode == HttpStatusCode.Accepted)
        {
            await Task.Delay(delayMs * (i + 1)); // Exponential backoff
            continue;
        }

        throw new Exception($"Failed to get order: {response.StatusCode}");
    }

    throw new Exception("Order not found after retries");
}

Eventual consistency requires different thinking about user experience. Instead of immediate consistency, you design for eventual consistency with appropriate user feedback.

Repository Pattern with CQRS

CQRS works beautifully with the repository pattern, but you'll often have separate repositories for reads and writes.

// Write repository - domain-focused
public interface IOrderRepository
{
    Task AddAsync(Order order);
    Task GetByIdAsync(int id);
    Task UpdateAsync(Order order);
    Task ExistsAsync(int id);
}

// Read repository - query-focused
public interface IOrderReadRepository
{
    Task GetByIdAsync(int id);
    Task> GetByCustomerAsync(string customerId);
    Task> GetRecentAsync(int count);
}

// Write repository implementation
public class OrderRepository : IOrderRepository
{
    private readonly OrderDbContext _context;

    public async Task AddAsync(Order order)
    {
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        return order.Id;
    }

    // Other write operations...
}

// Read repository implementation (could use EF projections, Dapper, etc.)
public class OrderReadRepository : IOrderReadRepository
{
    private readonly OrderReadDbContext _context;

    public async Task GetByIdAsync(int id)
    {
        return await _context.OrderSummaries
            .Where(o => o.Id == id)
            .Select(o => new OrderDto
            {
                Id = o.Id,
                CustomerName = o.CustomerName,
                Total = o.Total,
                Status = o.Status,
                CreatedAt = o.CreatedAt
            })
            .FirstOrDefaultAsync();
    }
}

The write repository works with domain entities, while the read repository works with DTOs. This separation allows you to optimize each for its specific purpose.

When CQRS Makes Sense (and When It Doesn't)

CQRS is powerful, but it's not a silver bullet. I've learned through experience when it provides value and when it's overkill.

Use CQRS when: - Your read and write workloads have different scaling requirements - You need different data models for reading and writing - You're dealing with complex business domains - You want to use event sourcing - Your team is comfortable with the additional complexity

Don't use CQRS for: - Simple CRUD applications - Teams new to domain-driven design - Applications where reads and writes are essentially the same - Projects with tight deadlines and simple requirements

CQRS adds complexity. Make sure the benefits outweigh the costs for your specific situation.

Common CQRS Pitfalls and How to Avoid Them

Over the years, I've seen teams struggle with the same CQRS implementation issues. Here are the most common problems and how to avoid them.

Over-complicating simple operations: Not every operation needs CQRS. Start with simple command- query separation and only add complexity when you need it.

Ignoring eventual consistency: Design your user experience around eventual consistency. Don't assume immediate consistency between reads and writes.

Mixing commands and queries: Keep them separate. Commands shouldn't return data, and queries shouldn't have side effects.

Complex event handling: Start simple with domain events. Don't try to handle every possible scenario in your event handlers.

Testing challenges: CQRS makes testing easier when done right, but poor separation can make it harder. Keep handlers focused and dependencies minimal.

Advanced CQRS: Event Sourcing Integration

CQRS pairs beautifully with event sourcing, where you store the history of changes rather than just the current state. This provides audit trails, temporal queries, and the ability to rebuild state.

// Event-sourced aggregate
public class Order : AggregateRoot
{
    private readonly List _changes = new();

    public void Create(string customerId, List items)
    {
        var @event = new OrderCreatedEvent(Id, customerId, items, DateTime.UtcNow);
        Apply(@event);
        _changes.Add(@event);
    }

    public void AddItem(OrderItem item)
    {
        var @event = new OrderItemAddedEvent(Id, item, DateTime.UtcNow);
        Apply(@event);
        _changes.Add(@event);
    }

    private void Apply(OrderCreatedEvent @event)
    {
        CustomerId = @event.CustomerId;
        Items = new List(@event.Items);
        Status = OrderStatus.Created;
        CreatedAt = @event.CreatedAt;
    }

    private void Apply(OrderItemAddedEvent @event)
    {
        Items.Add(@event.Item);
        // Recalculate total, update timestamps, etc.
    }

    public IEnumerable GetChanges() => _changes.AsReadOnly();
}

Event sourcing adds another layer of complexity but provides incredible power for auditing, debugging, and rebuilding state. It's overkill for most applications, but when you need it, it's invaluable.

Summary

CQRS and the Mediator pattern provide a powerful architectural approach for building scalable, maintainable applications. By separating commands from queries and using a mediator to handle communication, you create systems that are easier to understand, test, and modify.

The key insights are: separation of concerns makes code more modular and testable; domain events enable loose coupling; and read models can be optimized independently of write models for better performance. MediatR provides an excellent mediator implementation with pipeline behaviors for cross-cutting concerns. What makes CQRS compelling is how it scales-optimize reads and writes independently, use different databases, and scale each side based on its load patterns.