DEV Community

Cover image for CQRS and the Mediator Pattern in .NET with MediatR
Libin Tom Baby
Libin Tom Baby

Posted on

CQRS and the Mediator Pattern in .NET with MediatR

CQRS concept, IRequest, IRequestHandler, pipeline behaviors, MediatR setup, CQRS vs CRUD

CQRS and the Mediator pattern are two of the most widely used architectural patterns in modern .NET systems.

CQRS gives you the why — separate reads from writes.
MediatR gives you the how — a clean, decoupled way to dispatch commands and queries.


What Is CQRS?

CQRS stands for Command Query Responsibility Segregation.

Reads and writes are fundamentally different operations. Treat them differently.

Write operations → Commands (change state, return nothing or an ID)
Read operations  → Queries  (return data, change nothing)
Enter fullscreen mode Exit fullscreen mode

Before CQRS

public class OrderService
{
    public async Task CreateOrder(CreateOrderDto dto) { }
    public async Task<Order> GetOrder(Guid id) { }
    public async Task<List<Order>> GetOrdersByUser(Guid userId) { }
    public async Task CancelOrder(Guid id) { }
    public async Task UpdateOrder(Guid id, UpdateOrderDto dto) { }
}
Enter fullscreen mode Exit fullscreen mode

This service grows without limit. Every developer adds to it. It becomes untestable.

After CQRS

Commands: CreateOrderCommand, CancelOrderCommand, UpdateOrderCommand
Queries:  GetOrderQuery, GetOrdersByUserQuery
Enter fullscreen mode Exit fullscreen mode

Each operation is its own class. Each has its own handler. They are independent.


What Is the Mediator Pattern?

The Mediator pattern reduces coupling by routing messages through a central mediator.

// ❌ Controller directly calls service — tightly coupled
var service = new OrderService(db, email, logger);
service.CreateOrder(dto);

// ✅ Controller sends a command — doesn't know who handles it
await _mediator.Send(new CreateOrderCommand(dto));
Enter fullscreen mode Exit fullscreen mode

MediatR in Practice

Installation

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Enter fullscreen mode Exit fullscreen mode

Registration

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
Enter fullscreen mode Exit fullscreen mode

Commands — Write Operations

Define the command

public record CreateOrderCommand(string CustomerId, string Product, int Quantity)
    : IRequest<Guid>;
Enter fullscreen mode Exit fullscreen mode

Write the handler

public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly AppDbContext _db;
    private readonly IEmailService _email;

    public CreateOrderCommandHandler(AppDbContext db, IEmailService email)
    {
        _db = db;
        _email = email;
    }

    public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = cmd.CustomerId,
            Product = cmd.Product,
            Quantity = cmd.Quantity,
            Status = OrderStatus.Pending
        };

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

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

Send from the controller

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderCommand command)
{
    var id = await _mediator.Send(command);
    return CreatedAtAction(nameof(Get), new { id }, null);
}
Enter fullscreen mode Exit fullscreen mode

Queries — Read Operations

Define the query

public record GetOrdersByUserQuery(string CustomerId) : IRequest<List<OrderDto>>;
Enter fullscreen mode Exit fullscreen mode

Write the handler

public class GetOrdersByUserQueryHandler
    : IRequestHandler<GetOrdersByUserQuery, List<OrderDto>>
{
    private readonly AppDbContext _db;

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

    public async Task<List<OrderDto>> Handle(
        GetOrdersByUserQuery query, CancellationToken ct)
    {
        return await _db.Orders
            .Where(o => o.CustomerId == query.CustomerId)
            .Select(o => new OrderDto(o.Id, o.Product, o.Status))
            .AsNoTracking()
            .ToListAsync(ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

Pipeline Behaviours — Cross-Cutting Concerns

Pipeline behaviours wrap every command or query — perfect for logging, validation, and caching.

public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, 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)
    {
        _logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
        var response = await next();
        _logger.LogInformation("Handled {Request}", typeof(TRequest).Name);
        return response;
    }
}

// Register
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
Enter fullscreen mode Exit fullscreen mode

Validation with FluentValidation

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Quantity).GreaterThan(0);
    }
}

public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, 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)
    {
        var failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(e => e != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Interview-Ready Summary

  • CQRS separates reads (queries) from writes (commands)
  • The Mediator pattern routes messages through a central dispatcher — removing direct dependencies
  • MediatR is the standard .NET implementation of the Mediator pattern
  • Commands = write operations, implement IRequest<T>
  • Queries = read operations, implement IRequest<T>
  • Handlers implement IRequestHandler<TRequest, TResponse>
  • Pipeline behaviours add cross-cutting concerns: logging, validation, caching
  • Controllers send commands/queries — they don't know who handles them

A strong interview answer:

"CQRS separates reads and writes into distinct objects — Commands mutate state, Queries return data. MediatR implements the Mediator pattern by routing these objects to their handlers. This decouples controllers from services, makes each operation independently testable, and allows pipeline behaviours to wrap every request with logging, validation, or caching without any handler knowing about it."

Top comments (0)