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<ConfirmarPedidoResult> 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) => _connection = connection; public async Task<PedidoDto?> 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<PedidoDto>(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<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.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<LoggingBehavior<TRequest, TResponse>> logger) => _logger = logger; public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> 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 > 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<IPedidoRepository>(); repositoryMock.Setup(r => r.GetByIdAsync(pedidoId, default)).ReturnsAsync(pedido); var unitOfWorkMock = new Mock<IUnitOfWork>(); 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 => 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: