CQRS .NET C# MediatR Arquitetura

CQRS na prática com .NET e MediatR: separando leituras e escritas de verdade

Aprenda a implementar CQRS em projetos .NET com MediatR: commands, queries, handlers, validação com FluentValidation e pipeline behaviors.

N
Neryx Digital Architects
9 de outubro de 2025
13 min de leitura
220 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

CQRS — Command Query Responsibility Segregation — é um dos padrões mais transformadores para sistemas .NET com lógica de negócio complexa. A ideia central é simples: operações que modificam estado (commands) e operações que leem estado (queries) são responsabilidades diferentes, e misturá-las no mesmo objeto cria acoplamento desnecessário.

Junto com o MediatR, a implementação fica limpa, testável e extensível sem esforço. Neste guia você vai ver tudo isso na prática.

O problema que CQRS resolve

Considere um service típico sem CQRS:

public class PedidoService
{
    public async Task<Pedido> GetByIdAsync(Guid id) { ... }
    public async Task<List<Pedido>> GetByClienteAsync(Guid clienteId) { ... }
    public async Task ConfirmarAsync(Guid id) { ... }
    public async Task CancelarAsync(Guid id, string motivo) { ... }
    public async Task AdicionarItemAsync(Guid pedidoId, ItemDto item) { ... }
    public async Task<PagedResult<Pedido>> ListarComFiltrosAsync(FiltrosPedido filtros) { ... }
    // ... mais 10 métodos
}

Com o tempo esse service cresce sem controle. Cada método tem dependências diferentes (repositório, cache, serviço de e-mail), mas todas vivem na mesma classe. Testar um método exige mockar tudo que os outros usam.

Com CQRS cada operação é uma classe independente — com suas próprias dependências, validações e testes.

Instalando MediatR

dotnet add package MediatR
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions

// Program.cs
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

Commands: operações que modificam estado

Um command representa uma intenção de mudança. Ele carrega os dados necessários e pode retornar um resultado (ou ser void).

// Command (o "pedido de ação")
public record ConfirmarPedidoCommand(Guid PedidoId) : IRequest<ConfirmarPedidoResult>;

public record ConfirmarPedidoResult(Guid PedidoId, string Status, decimal Total);

// Handler (quem executa) public class ConfirmarPedidoHandler : IRequestHandler<ConfirmarPedidoCommand, ConfirmarPedidoResult> { private readonly IPedidoRepository _repository; private readonly IUnitOfWork _unitOfWork;

public ConfirmarPedidoHandler(IPedidoRepository repository, IUnitOfWork unitOfWork)
{
    _repository = repository;
    _unitOfWork = unitOfWork;
}

public async Task&lt;ConfirmarPedidoResult&gt; Handle(
    ConfirmarPedidoCommand request,
    CancellationToken cancellationToken)
{
    var pedido = await _repository.GetByIdAsync(request.PedidoId, cancellationToken)
        ?? throw new NotFoundException($"Pedido {request.PedidoId} não encontrado.");

    pedido.Confirmar(); // lógica de domínio na entidade

    await _unitOfWork.SaveChangesAsync(cancellationToken);

    return new ConfirmarPedidoResult(pedido.Id, pedido.Status.ToString(), pedido.CalcularTotal());
}

}

// Controller — fica mínimo [HttpPost(“{id}/confirmar”)] public async Task<IActionResult> Confirmar(Guid id) { var result = await _mediator.Send(new ConfirmarPedidoCommand(id)); return Ok(result); }

Queries: operações de leitura

Queries não alteram estado. Podem ter modelos de leitura (DTOs) otimizados, completamente independentes do modelo de domínio — inclusive com queries SQL diretas quando o ORM for lento.

// Query
public record GetPedidoQuery(Guid PedidoId) : IRequest<PedidoDto?>;

public record PedidoDto( Guid Id, string ClienteNome, string Status, decimal Total, int TotalItens, DateTime CriadoEm);

// Handler de leitura — pode usar Dapper para performance máxima public class GetPedidoHandler : IRequestHandler<GetPedidoQuery, PedidoDto?> { private readonly IDbConnection _connection;

public GetPedidoHandler(IDbConnection connection) =&gt; _connection = connection;

public async Task&lt;PedidoDto?&gt; Handle(GetPedidoQuery request, CancellationToken ct)
{
    const string sql = @"
        SELECT p.id, c.nome AS clienteNome, p.status, p.total,
               COUNT(i.id) AS totalItens, p.criado_em AS criadoEm
        FROM pedidos p
        JOIN clientes c ON c.id = p.cliente_id
        LEFT JOIN itens_pedido i ON i.pedido_id = p.id
        WHERE p.id = @PedidoId
        GROUP BY p.id, c.nome, p.status, p.total, p.criado_em";

    return await _connection.QueryFirstOrDefaultAsync&lt;PedidoDto&gt;(sql, new { request.PedidoId });
}

}

// Query de listagem com paginação public record ListarPedidosQuery( Guid? ClienteId, string? Status, int Page = 1, int PageSize = 20) : IRequest<PagedResult<PedidoDto>>;

public record PagedResult<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize) { public int TotalPages => (int)Math.Ceiling(Total / (double)PageSize); public bool HasNext => Page < TotalPages; public bool HasPrevious => Page > 1; }

Validação com FluentValidation + Pipeline Behavior

Pipeline Behaviors são middlewares do MediatR — executam antes ou depois de qualquer handler. Perfeitos para validação, logging e tratamento de erros transversais.

// Validator do command
public class ConfirmarPedidoCommandValidator : AbstractValidator<ConfirmarPedidoCommand>
{
    public ConfirmarPedidoCommandValidator()
    {
        RuleFor(x => x.PedidoId)
            .NotEmpty().WithMessage("PedidoId é obrigatório.");
    }
}

// Pipeline behavior que executa a validação automaticamente public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse> { private readonly IEnumerable<IValidator<TRequest>> _validators;

public ValidationBehavior(IEnumerable&lt;IValidator&lt;TRequest&gt;&gt; validators)
    =&gt; _validators = validators;

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

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

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

    return await next();
}

}

// Pipeline behavior para logging public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse> { private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

public LoggingBehavior(ILogger&lt;LoggingBehavior&lt;TRequest, TResponse&gt;&gt; logger)
    =&gt; _logger = logger;

public async Task&lt;TResponse&gt; Handle(
    TRequest request,
    RequestHandlerDelegate&lt;TResponse&gt; next,
    CancellationToken cancellationToken)
{
    var requestName = typeof(TRequest).Name;
    _logger.LogInformation("Executando {RequestName}: {@Request}", requestName, request);

    var sw = Stopwatch.StartNew();
    var response = await next();
    sw.Stop();

    if (sw.ElapsedMilliseconds &gt; 500)
        _logger.LogWarning("Request lenta: {RequestName} levou {Elapsed}ms", requestName, sw.ElapsedMilliseconds);

    return response;
}

}

// Registrar os behaviors em Program.cs builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));

Estrutura de pastas com CQRS

src/Application/
├── Pedidos/
│   ├── Commands/
│   │   ├── ConfirmarPedido/
│   │   │   ├── ConfirmarPedidoCommand.cs
│   │   │   ├── ConfirmarPedidoHandler.cs
│   │   │   └── ConfirmarPedidoCommandValidator.cs
│   │   └── CancelarPedido/
│   │       ├── CancelarPedidoCommand.cs
│   │       └── CancelarPedidoHandler.cs
│   └── Queries/
│       ├── GetPedido/
│       │   ├── GetPedidoQuery.cs
│       │   ├── GetPedidoHandler.cs
│       │   └── PedidoDto.cs
│       └── ListarPedidos/
│           ├── ListarPedidosQuery.cs
│           └── ListarPedidosHandler.cs
└── Behaviors/
    ├── ValidationBehavior.cs
    └── LoggingBehavior.cs

Cada feature fica totalmente encapsulada na própria pasta. Adicionar uma nova operação significa criar uma nova pasta — sem tocar em código existente.

Testando commands e queries isoladamente

public class ConfirmarPedidoHandlerTests
{
    [Fact]
    public async Task Handle_PedidoValido_RetornaStatusConfirmado()
    {
        // Arrange
        var pedidoId = Guid.NewGuid();
        var pedido = CriarPedidoComItens(pedidoId);
    var repositoryMock = new Mock&lt;IPedidoRepository&gt;();
    repositoryMock.Setup(r =&gt; r.GetByIdAsync(pedidoId, default)).ReturnsAsync(pedido);

    var unitOfWorkMock = new Mock&lt;IUnitOfWork&gt;();
    var handler = new ConfirmarPedidoHandler(repositoryMock.Object, unitOfWorkMock.Object);

    // Act
    var result = await handler.Handle(new ConfirmarPedidoCommand(pedidoId), default);

    // Assert
    result.Status.Should().Be("Confirmado");
    unitOfWorkMock.Verify(u =&gt; u.SaveChangesAsync(default), Times.Once);
}

}

Cada handler tem exatamente as dependências que precisa — sem sobrecarga de mocking.

CQRS e separação de banco de dados

A versão avançada do CQRS usa bancos de dados separados: o lado de escrita usa um banco normalizado (PostgreSQL + EF Core), enquanto o lado de leitura usa uma projeção otimizada para consulta (um banco de leitura denormalizado, Redis, ou Elasticsearch). As queries ficam extremamente rápidas porque a estrutura já está montada para aquela consulta específica.

Essa separação não é necessária na maioria dos sistemas. Comece com um banco único — você pode separar depois se a escala exigir, sem mudar a estrutura de commands e queries.

Quando CQRS faz sentido

CQRS agrega valor quando: as operações de leitura e escrita têm requisitos de performance muito diferentes; o domínio tem muitas operações com lógicas complexas; o time é grande e precisa trabalhar em features paralelas sem conflito. Não vale a pena em CRUDs simples ou projetos com poucos desenvolvedores e baixa complexidade.

Conclusão

CQRS com MediatR transforma a forma como você organiza código em .NET. Controllers ficam com 2-3 linhas. Cada operação tem responsabilidade única e dependências mínimas. Adicionar validação, logging ou caching transversal vira um pipeline behavior de poucas linhas sem tocar nos handlers existentes.

Se você está estruturando um projeto .NET e quer aplicar CQRS, Clean Architecture e DDD de forma coerente, a Neryx tem experiência com esses padrões em sistemas de produção. Consultoria inicial gratuita.

Leitura complementar:

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.