DEV Community

Cover image for .NET CQRS Deep Dive: Real-World Example with MediatR
Vikrant Bagal
Vikrant Bagal

Posted on

.NET CQRS Deep Dive: Real-World Example with MediatR

CQRS — Command Query Responsibility Segregation — gets a bad reputation for complexity. Most developers picture microservices, Kafka, and whiteboards full of arrows. But it doesn't have to be that way.

.NET CQRS Deep Dive: Real-World Example with MediatR

In this deep dive, I'll show you how to implement CQRS in .NET with a real production-ready example that you can apply immediately.

What CQRS Actually Means

The core principle is simple:

  • Commands change state (create, update, delete). They return minimal data.
  • Queries read state. They never mutate anything.

That's it. No mandatory bus. No separate databases. No PhD in messaging.

The Problem with Traditional CRUD

In a traditional CRUD API, the same service handles everything. This works at small scale, but you'll hit friction points:

  • A read query unnecessarily loads a domain model packed with validation logic
  • A write operation eager-loads relationships just to compute a return value
  • Your OrderService balloons to 1,500 lines and every change feels risky

Real-World Example: Order Management

Let's build an Order management feature — the kind you'd see in an e-commerce or inventory system.

Project Structure

I use a feature-slice layout inspired by Vertical Slice Architecture:

/Features
  /Orders
    /Commands
      CreateOrderCommand.cs
      CreateOrderCommandHandler.cs
      UpdateOrderStatusCommand.cs
      UpdateOrderStatusCommandHandler.cs
    /Queries
      GetOrderByIdQuery.cs
      GetOrderByIdQueryHandler.cs
      GetOrdersByCustomerQuery.cs
      GetOrdersByCustomerQueryHandler.cs
    OrderDto.cs
Enter fullscreen mode Exit fullscreen mode

This beats the traditional /Services, /Repositories, /Models split. You stop hunting across folders.

Implementing Commands

The CreateOrder Command

// CreateOrderCommand.cs
public record CreateOrderCommand(
    Guid CustomerId,
    List<OrderItemDto> Items,
    ShippingAddress Address
) : IRequest<Guid>;
Enter fullscreen mode Exit fullscreen mode
// CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler 
    : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly AppDbContext _db;

    public CreateOrderCommandHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Business validation
        if (!await _db.Customers.ExistsAsync(request.CustomerId, cancellationToken))
            throw new NotFoundException("Customer not found");

        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            Status = OrderStatus.Pending,
            Items = request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = await _db.Products.GetPriceAsync(i.ProductId)
            }).ToList(),
            ShippingAddress = request.Address,
            CreatedAt = DateTime.UtcNow
        };

        // Domain event for side effects
        order.AddDomainEvent(new OrderCreatedEvent(order.Id, order.CustomerId));

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

Key insight: Commands do work and return only the new ID. No need to load the full object back.

The UpdateOrderStatus Command

// UpdateOrderStatusCommand.cs
public record UpdateOrderStatusCommand(
    Guid OrderId,
    OrderStatus NewStatus,
    string Reason
) : IRequest<bool>;
Enter fullscreen mode Exit fullscreen mode
// UpdateOrderStatusCommandHandler.cs
public class UpdateOrderStatusCommandHandler 
    : IRequestHandler<UpdateOrderStatusCommand, bool>
{
    private readonly AppDbContext _db;
    private readonly IEventBus _eventBus;

    public UpdateOrderStatusCommandHandler(
        AppDbContext db,
        IEventBus eventBus)
    {
        _db = db;
        _eventBus = eventBus;
    }

    public async Task<bool> Handle(
        UpdateOrderStatusCommand request,
        CancellationToken cancellationToken)
    {
        var order = await _db.Orders
            .FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);

        if (order is null)
            return false;

        var previousStatus = order.Status;
        order.UpdateStatus(request.NewStatus, request.Reason);

        // Publish events for status changes
        if (request.NewStatus == OrderStatus.Shipped)
        {
            order.AddDomainEvent(new OrderShippedEvent(order.Id));
        }

        await _db.SaveChangesAsync(cancellationToken);
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Implementing Queries

The GetOrderById Query

// GetOrderByIdQuery.cs
public record GetOrderByIdQuery(Guid OrderId) 
    : IRequest<OrderDetailDto?>;
Enter fullscreen mode Exit fullscreen mode
// GetOrderByIdQueryHandler.cs
public class GetOrderByIdQueryHandler 
    : IRequestHandler<GetOrderByIdQuery, OrderDetailDto?>
{
    private readonly AppDbContext _db;

    public GetOrderByIdQueryHandler(AppDbContext db)
    {
        _db = db;
    }

    public async Task<OrderDetailDto?> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        return await _db.Orders
            .AsNoTracking()
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDetailDto(
                o.Id,
                o.CustomerId,
                o.Customer.Name,
                o.Status,
                o.Items.Select(i => new OrderItemDto(
                    i.ProductId,
                    i.Product.Name,
                    i.Quantity,
                    i.UnitPrice
                )).ToList(),
                o.ShippingAddress.ToDto(),
                o.TotalAmount,
                o.CreatedAt,
                o.UpdatedAt
            ))
            .FirstOrDefaultAsync(cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

Two critical optimizations:

  1. AsNoTracking() — Queries never mutate data, so EF's change tracker is pure overhead. This alone cuts query times by 20-30% on high-volume endpoints.

  2. Project directly to DTO in SQL — Use .Select() to project at the database level. Never load full entities when you only need a few columns.

The GetOrdersByCustomer Query (Read Model Optimization)

// GetOrdersByCustomerQuery.cs  
public record GetOrdersByCustomerQuery(
    Guid CustomerId,
    int Page = 1,
    int PageSize = 20
) : IRequest<PagedResult<OrderSummaryDto>>;
Enter fullscreen mode Exit fullscreen mode
// GetOrdersByCustomerQueryHandler.cs
public class GetOrdersByCustomerQueryHandler 
    : IRequestHandler<GetOrdersByCustomerQuery, PagedResult<OrderSummaryDto>>
{
    private readonly ReadOnlyDbContext _readDb; // Separate read DB

    public GetOrdersByCustomerQueryHandler(ReadOnlyDbContext readDb)
    {
        _readDb = readDb;
    }

    public async Task<PagedResult<OrderSummaryDto>> Handle(
        GetOrdersByCustomerQuery request,
        CancellationToken cancellationToken)
    {
        var query = _readDb.OrderSummaries
            .AsNoTracking()
            .Where(s => s.CustomerId == request.CustomerId);

        var total = await query.CountAsync(cancellationToken);

        var items = await query
            .OrderByDescending(o => o.CreatedAt)
            .Skip((request.Page - 1) * request.PageSize)
            .Take(request.PageSize)
            .ToListAsync(cancellationToken);

        return new PagedResult<OrderSummaryDto>(items, total, request.Page, request.PageSize);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the separate read database context. This is the CQRS pattern scaling up — different models optimized for different workloads.

Pipeline Behaviors: Validation & Logging

Don't put validation inside handlers. Use MediatR's IPipelineBehavior:

public class ValidationBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);

        return await next();
    }
}
Enter fullscreen mode Exit fullscreen mode
// FluentValidation example
public class CreateOrderCommandValidator 
    : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty();

        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Order must have at least one item");

        RuleForEach(x => x.Items)
            .ChildRules(item => 
            {
                item.RuleFor(i => i.Quantity)
                    .GreaterThan(0);
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Thin Controllers

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

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

    [HttpPost]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken ct)
    {
        var id = await _mediator.Send(command, ct);
        return CreatedAtAction(nameof(GetById), new { id }, new { Id = id });
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);
        return result is null ? NotFound() : Ok(result);
    }

    [HttpGet("customer/{customerId:guid}")]
    public async Task<IActionResult> GetByCustomer(
        Guid customerId,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20,
        CancellationToken ct = default)
    {
        var result = await _mediator.Send(
            new GetOrdersByCustomerQuery(customerId, page, pageSize), ct);
        return Ok(result);
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller is now a thin routing layer. It knows nothing about databases, business rules, or domain models.

When NOT to Use CQRS

I've seen teams apply CQRS to every microservice regardless of complexity. For a service with 5 endpoints and simple CRUD, it's overkill. Apply it when:

  • The domain has real business logic beyond simple data mapping
  • Read and write loads are significantly asymmetric
  • Multiple developers work on the same service simultaneously
  • You need clear audit trails or plan to add Event Sourcing later

For simpler services, a straightforward repository pattern or minimal API is the better call.

Key Takeaways

  1. CQRS is a code-level pattern — separate read/write models in code, not necessarily separate databases
  2. MediatR makes it clean — thin controllers, focused handlers, easy unit testing
  3. Always use AsNoTracking() in query handlers — EF's change tracker is pure overhead
  4. Project to DTOs at the database level with .Select() — never load full entities when you only need a few columns
  5. Pipeline behaviors handle cross-cutting concerns — keep handlers focused on one job
  6. Return minimal data from commands — just IDs, not entire objects
  7. CQRS + caching is a natural fit — query handlers are pure reads, ideal for Redis

Resources

What patterns have worked for your team? Share your experiences in the comments.


Connect with me:
[www.linkedin.com/in/vikrant-bagal]

Top comments (0)