CQRS with MediatR in .NET: Separating Reads from Writes

CQRS — Command Query Responsibility Segregation — is the idea that your read and write models should be separate. Commands change state. Queries return data. They have different requirements, different performance profiles, and different scaling characteristics. Treating them identically is a missed opportunity.

MediatR, created by Jimmy Bogard, gives you an in-process mediator that makes implementing CQRS in .NET straightforward without the overhead of full-blown message buses.

Setting Up

Install the packages:

dotnet add package MediatR

Register MediatR in Program.cs:

Program.cs
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<Program>());

Defining Commands

Commands represent intentions to change state. They return either a result or nothing.

PlaceOrderHandler.cs
public record PlaceOrderCommand(
    string CustomerEmail,
    List<OrderLineDto> Lines) : IRequest<Guid>;

public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
    private readonly AppDbContext _db;

    public PlaceOrderHandler(AppDbContext db) => _db = db;

    public async Task<Guid> Handle(PlaceOrderCommand request, CancellationToken ct)
    {
        var order = new Order(request.CustomerEmail);

        foreach (var line in request.Lines)
            order.AddLine(line.Product, line.Quantity, line.UnitPrice);

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

        return order.Id;
    }
}

Defining Queries

Queries return data and never modify state. They can be optimised independently — using raw SQL, Dapper, read replicas, or cached projections.

GetOrderByIdHandler.cs
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDetailsDto?>;

public class GetOrderByIdHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailsDto?>
{
    private readonly AppDbContext _db;

    public GetOrderByIdHandler(AppDbContext db) => _db = db;

    public async Task<OrderDetailsDto?> Handle(
        GetOrderByIdQuery request, CancellationToken ct)
    {
        return await _db.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDetailsDto
            {
                Id = o.Id,
                CustomerEmail = o.CustomerEmail,
                Total = o.Lines.Sum(l => l.Quantity * l.UnitPrice),
                LineCount = o.Lines.Count,
                CreatedAt = o.CreatedAt
            })
            .FirstOrDefaultAsync(ct);
    }
}

Notice the query projects directly into a DTO. There's no need to load full entities for read operations.

Using in an Endpoint

Program.cs
app.MapPost("/api/orders", async (PlaceOrderCommand command, IMediator mediator) =>
{
    var id = await mediator.Send(command);
    return Results.Created($"/api/orders/{id}", new { id });
});

app.MapGet("/api/orders/{id:guid}", async (Guid id, IMediator mediator) =>
{
    var order = await mediator.Send(new GetOrderByIdQuery(id));
    return order is not null ? Results.Ok(order) : Results.NotFound();
});

Pipeline Behaviours

The real power of MediatR is pipeline behaviours — middleware for your handlers. They let you add cross-cutting concerns without modifying individual handlers.

Logging Behaviour

LoggingBehaviour.cs
public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;

    public LoggingBehaviour(ILogger<LoggingBehaviour<TRequest, TResponse>> logger)
        => _logger = logger;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", requestName);

        var response = await next();

        _logger.LogInformation("Handled {RequestName}", requestName);

        return response;
    }
}

Validation Behaviour

Pair MediatR with FluentValidation to validate every command automatically:

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

    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

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

        var context = new ValidationContext<TRequest>(request);

        var failures = (await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, ct))))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Register behaviours in Program.cs:

Program.cs
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));

When CQRS Makes Sense

CQRS adds value when your reads and writes have different shapes, when you need to optimise queries independently, or when you want a clean pipeline for cross-cutting concerns. It does not require separate databases or event sourcing — those are optional additions.

For a simple CRUD application where reads and writes look identical, CQRS is overhead. But for any system with complex query requirements or business logic, separating the two paths gives you clarity and room to optimise each side independently.