CQRS is often perceived as overly complex, but applying it in .NET with MediatR can actually distill it down to its elegant, decoupled essence.

Let’s see it in action. Imagine a simple e-commerce order processing system. We’ll have a command to create an order and a query to retrieve order details.

First, the command handler for creating an order:

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderCreatedEvent>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IEventBus _eventBus; // For publishing events

    public CreateOrderCommandHandler(IOrderRepository orderRepository, IEventBus eventBus)
    {
        _orderRepository = orderRepository;
        _eventBus = eventBus;
    }

    public async Task<OrderCreatedEvent> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        var order = new Order(request.CustomerId, request.Items);
        await _orderRepository.AddAsync(order);
        var orderCreatedEvent = new OrderCreatedEvent(order.Id, order.CustomerId, order.OrderDate);
        await _eventBus.PublishAsync(orderCreatedEvent); // Publish the event
        return orderCreatedEvent;
    }
}

And the command itself:

public class CreateOrderCommand : IRequest<OrderCreatedEvent>
{
    public Guid CustomerId { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public class OrderItemDto { /* ... */ }
public class OrderCreatedEvent { /* ... */ }

Now, a query handler to get order details:

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailsDto>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDetailsDto> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
    {
        var order = await _orderRepository.GetByIdAsync(request.OrderId);
        if (order == null)
        {
            return null; // Or throw an exception
        }
        return new OrderDetailsDto { /* map order to DTO */ };
    }
}

And the query:

public class GetOrderByIdQuery : IRequest<OrderDetailsDto>
{
    public Guid OrderId { get; set; }
}

public class OrderDetailsDto { /* ... */ }

In your Startup.cs or Program.cs, you’d register MediatR and your handlers:

services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Register your repositories and event bus implementations
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddSingleton<IEventBus, InMemoryEventBus>(); // Example

When a request comes in (e.g., a POST to /orders for CreateOrderCommand or a GET to /orders/{id} for GetOrderByIdQuery), you inject IMediator and send the request:

// In a controller
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderCommand command)
{
    var result = await _mediator.Send(command);
    return Ok(result);
}

[HttpGet("orders/{orderId}")]
public async Task<IActionResult> GetOrder(Guid orderId)
{
    var query = new GetOrderByIdQuery { OrderId = orderId };
    var result = await _mediator.Send(query);
    return Ok(result);
}

This setup elegantly separates command (write) and query (read) concerns. Commands represent an intent to change state, and they typically return an event or a simple acknowledgment. Queries represent an intent to retrieve data and return a DTO. MediatR acts as the central dispatcher, finding the correct handler for each request without tight coupling.

The real power emerges when you consider event handling. In the CreateOrderCommandHandler, we published an OrderCreatedEvent. You can then have other handlers that listen for this event. For example, an EmailNotificationService that sends an email, or an InventoryService that decrements stock. These handlers also implement INotificationHandler<OrderCreatedEvent> and are registered with MediatR. MediatR will then fan out the notification to all interested handlers.

public class OrderConfirmationEmailHandler : INotificationHandler<OrderCreatedEvent>
{
    private readonly IEmailService _emailService;

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

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

This event-driven aspect is crucial for decoupling side effects from the initial command execution, making your system more resilient and extensible.

What most people miss is that your "command" handlers aren’t just for direct state mutations. They are excellent places to trigger asynchronous workflows or integrate with other services via events. The OrderCreatedEvent isn’t just a return value; it’s a message published to a bus (even an in-memory one), allowing other parts of the system to react independently. This pattern allows you to build complex business logic that responds to state changes without the original command handler needing to know about any of those downstream services.

The next step is to explore how to implement separate read models for your queries, often populated by those same events your commands publish.

Want structured learning?

Take the full Cqrs course →