Artigo

CQRS na Prática: Como Separar Leitura e Escrita Transforma Sua Arquitetura

14 min de leitura

Introdução

Você já tentou fazer uma query complexa num sistema que tem um monte de regra de negócio no mesmo modelo? Tipo, você precisa listar pedidos com status, nome do cliente, último pagamento, é o total com desconto aplicado. Aí você vai la, faz um JOIN de 5 tabelas, filtra por 3 condições, e reza pro Entity Framework não gerar um SQL de 200 linhas.

O problema tá no fato de que você tá usando o mesmo modelo pra duas coisas completamente diferentes: gravar dados com regras de negócio e ler dados pra exibir na tela. É isso, mais cedo ou mais tarde, vira um monstro.

É exatamente isso que o CQRS resolve. CQRS significa Command Query Responsibility Segregation, que em bom português é: separar a responsabilidade de quem escreve da responsabilidade de quem lê. Parece simples, mas muda completamente a forma como você pensa arquitetura.


O Problema do Modelo Único

Vamos começar pelo cenário clássico. Você tem uma aplicação de e-commerce. Tem la sua entidade Pedido:

1public class Pedido 2{ 3 public Guid Id { get; set; } 4 public Guid ClienteId { get; set; } 5 public Cliente Cliente { get; set; } 6 public List<ItemPedido> Itens { get; set; } 7 public decimal Total { get; set; } 8 public decimal Desconto { get; set; } 9 public StatusPedido Status { get; set; } 10 public DateTime CriadoEm { get; set; } 11 public DateTime? AtualizadoEm { get; set; } 12 public Pagamento Pagamento { get; set; } 13 public Endereco EnderecoEntrega { get; set; } 14}

Essa entidade serve pra tudo: criar pedido, atualizar status, calcular frete, aplicar cupom, listar pedidos do cliente, gerar relatório de vendas...

Agora pensa comigo: quando você cria um pedido, você precisa validar estoque, calcular preço, aplicar regras de desconto. Você precisa de toda a complexidade do domínio.

Mas quando você lista pedidos na tela do admin? Você só precisa de meia dúzia de campos. Número do pedido, nome do cliente, valor total, status. Pronto.

Usar o mesmo modelo pros dois casos e forçar um sapato número 38 num pe 42. Funciona? Até funciona. Mas dói.

Os problemas que aparecem:

  • Queries ficam lentas porque o modelo de domínio não foi feito pra ser consultado
  • O modelo fica inchado com propriedades que só existem pra leitura
  • Mudanças no domínio quebram as queries e vice-versa
  • Otimizar leitura significa poluir o modelo de escrita com índices, campos calculados, desnormalizações

O Que é CQRS, Afinal?

CQRS é a ideia de que comandos (operações que mudam estado) e queries (operações que leem estado) devem ser tratados por modelos separados.

Visualmente, é isso:

Do lado esquerdo, você tem os Commands: operações que alteram o estado do sistema. Criar pedido, cancelar pedido, aplicar desconto.

Do lado direito, você tem as Queries: operações que só leem dados. Listar pedidos, buscar detalhes, gerar relatório.

Cada lado tem seu próprio modelo, otimizado pro que ele faz de melhor.


O Lado da Escrita: Commands

O lado de escrita é onde mora a complexidade do negócio. Aqui você tem validações, regras, invariantes.

Um Command é basicamente uma intenção. "Eu quero criar um pedido". "Eu quero cancelar esse pedido". É uma instrução, não uma pergunta.

1public class CriarPedidoCommand 2{ 3 public Guid ClienteId { get; set; } 4 public List<ItemDto> Itens { get; set; } 5 public string CupomDesconto { get; set; } 6 public Guid EnderecoEntregaId { get; set; } 7}

Percebe que o Command não tem Id, não tem Total, não tem Status. Ele só carrega a intenção e os dados necessários pra executar.

Quem processa é o Command Handler:

1public class CriarPedidoHandler : ICommandHandler<CriarPedidoCommand> 2{ 3 private readonly IPedidoRepository _repository; 4 private readonly IEstoqueService _estoque; 5 private readonly ICupomService _cupons; 6 7 public async Task Handle(CriarPedidoCommand cmd) 8 { 9 // 1. Validar estoque 10 foreach (var item in cmd.Itens) 11 { 12 var disponivel = await _estoque.VerificarDisponibilidade( 13 item.ProdutoId, item.Quantidade 14 ); 15 16 if (!disponivel) 17 throw new EstoqueInsuficienteException(item.ProdutoId); 18 } 19 20 // 2. Aplicar cupom (se houver) 21 decimal desconto = 0; 22 if (!string.IsNullOrEmpty(cmd.CupomDesconto)) 23 { 24 desconto = await _cupons.CalcularDesconto( 25 cmd.CupomDesconto, cmd.Itens 26 ); 27 } 28 29 // 3. Criar o pedido com as regras de dominio 30 var pedido = Pedido.Criar( 31 cmd.ClienteId, 32 cmd.Itens.Select(i => new ItemPedido(i.ProdutoId, i.Quantidade, i.Preco)), 33 desconto, 34 cmd.EnderecoEntregaId 35 ); 36 37 // 4. Persistir 38 await _repository.Salvar(pedido); 39 40 // 5. Reservar estoque 41 await _estoque.Reservar(pedido.Itens); 42 } 43}

O handler faz uma coisa: processa a intenção. Ele valida, aplica regras, persiste. Sem se preocupar com como os dados vao ser exibidos depois.

O modelo de escrita e normalizado, rico em comportamento, focado em proteger as regras de negócio.


O Lado da Leitura: Queries

Agora o outro lado. Aqui a gente não precisa de regra de negócio nenhuma. A gente só precisa de dados prontos pra exibir.

O modelo de leitura pode ser completamente diferente do modelo de escrita:

1// Modelo de leitura - otimizado pra exibicao 2public class PedidoResumoView 3{ 4 public Guid Id { get; set; } 5 public string NumeroPedido { get; set; } 6 public string NomeCliente { get; set; } 7 public int QuantidadeItens { get; set; } 8 public decimal ValorTotal { get; set; } 9 public string Status { get; set; } 10 public DateTime Data { get; set; } 11}

Percebe: sem navegação pra Cliente, sem lista de Itens, sem Pagamento. Só o que a tela precisa. Flat, simples, direto.

E a query pode ir direto no banco com SQL otimizado:

1public class ListarPedidosQuery : IQueryHandler<ListarPedidosRequest, List<PedidoResumoView>> 2{ 3 private readonly IDbConnection _readDb; 4 5 public async Task<List<PedidoResumoView>> Handle(ListarPedidosRequest request) 6 { 7 var sql = @" 8 SELECT 9 p.id, 10 p.numero_pedido AS NumeroPedido, 11 c.nome AS NomeCliente, 12 p.quantidade_itens AS QuantidadeItens, 13 p.valor_total AS ValorTotal, 14 p.status AS Status, 15 p.criado_em AS Data 16 FROM pedidos_view p 17 INNER JOIN clientes c ON c.id = p.cliente_id 18 WHERE (@Status IS NULL OR p.status = @Status) 19 ORDER BY p.criado_em DESC 20 OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY"; 21 22 return (await _readDb.QueryAsync<PedidoResumoView>(sql, new 23 { 24 request.Status, 25 request.Offset, 26 request.Limit 27 })).ToList(); 28 } 29}

Sem ORM pesado, sem lazy loading, sem N+1. Query limpa, rápida, otimizada.

Você pode até usar um banco diferente pro lado de leitura. Um banco relacional pro lado de escrita (que precisa de transações e consistência) é um banco de documentos ou até um cache pro lado de leitura (que precisa de velocidade).


Sincronização: Como os Dois Lados Conversam?

Se você tem dois modelos separados (e potencialmente dois bancos separados), como o lado de leitura fica atualizado quando o lado de escrita muda?

A resposta: eventos.

Quando um comando e processado e altera o estado, ele publica um evento. Esse evento e consumido por um handler que atualiza o modelo de leitura.

O código do projetor (ou denormalizador) seria algo assim:

1public class PedidoProjector : 2 IEventHandler<PedidoCriadoEvent>, 3 IEventHandler<PedidoCanceladoEvent>, 4 IEventHandler<PedidoPagoEvent> 5{ 6 private readonly IDbConnection _readDb; 7 8 public async Task Handle(PedidoCriadoEvent evt) 9 { 10 await _readDb.ExecuteAsync(@" 11 INSERT INTO pedidos_view 12 (id, numero_pedido, cliente_id, quantidade_itens, 13 valor_total, status, criado_em) 14 VALUES 15 (@Id, @NumeroPedido, @ClienteId, @QuantidadeItens, 16 @ValorTotal, 'Criado', @CriadoEm)", 17 new 18 { 19 evt.PedidoId, 20 evt.NumeroPedido, 21 evt.ClienteId, 22 evt.QuantidadeItens, 23 evt.ValorTotal, 24 evt.CriadoEm 25 }); 26 } 27 28 public async Task Handle(PedidoCanceladoEvent evt) 29 { 30 await _readDb.ExecuteAsync(@" 31 UPDATE pedidos_view 32 SET status = 'Cancelado', atualizado_em = @AtualizadoEm 33 WHERE id = @PedidoId", 34 new { evt.PedidoId, evt.AtualizadoEm }); 35 } 36 37 public async Task Handle(PedidoPagoEvent evt) 38 { 39 await _readDb.ExecuteAsync(@" 40 UPDATE pedidos_view 41 SET status = 'Pago', atualizado_em = @AtualizadoEm 42 WHERE id = @PedidoId", 43 new { evt.PedidoId, evt.AtualizadoEm }); 44 } 45}

Cada evento sabe exatamente o que mudar no modelo de leitura. Simples, rápido, desacoplado.


Eventual Consistency: O Elefante na Sala

Quando você separa escrita e leitura com eventos no meio, você abre mao de consistência imediata. O dado foi salvo no banco de escrita, mas o modelo de leitura pode levar alguns milissegundos (ou segundos) pra refletir a mudança.

Isso se chama consistência eventual (eventual consistency). E significa que, por um breve período, o lado de leitura pode estar "desatualizado".

Na prática, pra maioria dos sistemas, isso não é um problema. Pensa no Instagram: quando você posta uma foto, ela aparece pra você na hora, mas pode levar alguns segundos pra aparecer no feed dos seus seguidores. Você nem percebe.

Mas tem casos onde isso importa. Sistemas financeiros, por exemplo, onde você precisa garantir que o saldo tá correto na hora da transação. Nesses casos, você pode:

  1. Manter consistência forte no que importa: a escrita valida tudo no momento da transação
  2. Aceitar eventual consistency no que não importa: o relatório de vendas pode ter 2 segundos de atraso sem problema
  3. Usar patterns como Read-your-writes: apos um comando, forçar a leitura do banco de escrita por um curto período

A chave e entender: você não precisa de consistência imediata em tudo. Você precisa de consistência imediata onde o negócio exige. No resto, eventual consistency é totalmente aceitavel.


Arquitetura Prática: E-Commerce com CQRS

Vamos montar uma arquitetura real. Um e-commerce com catálogo, pedidos e pagamentos.

Estrutura de Pastas

src/
|-- ECommerce.Commands/
|   |-- Pedidos/
|   |   |-- CriarPedidoCommand.cs
|   |   |-- CriarPedidoHandler.cs
|   |   |-- CancelarPedidoCommand.cs
|   |   |-- CancelarPedidoHandler.cs
|   |-- Pagamentos/
|   |   |-- ProcessarPagamentoCommand.cs
|   |   |-- ProcessarPagamentoHandler.cs
|   |-- Catalogo/
|       |-- AtualizarEstoqueCommand.cs
|       |-- AtualizarEstoqueHandler.cs
|
|-- ECommerce.Queries/
|   |-- Pedidos/
|   |   |-- ListarPedidosQuery.cs
|   |   |-- DetalhesPedidoQuery.cs
|   |   |-- RelatorioPedidosQuery.cs
|   |-- Catalogo/
|   |   |-- BuscarProdutosQuery.cs
|   |   |-- ProdutoDetalheQuery.cs
|   |-- ViewModels/
|       |-- PedidoResumoView.cs
|       |-- PedidoDetalheView.cs
|       |-- ProdutoCatalogoView.cs
|
|-- ECommerce.Domain/
|   |-- Pedidos/
|   |   |-- Pedido.cs
|   |   |-- ItemPedido.cs
|   |   |-- Events/
|   |       |-- PedidoCriadoEvent.cs
|   |       |-- PedidoCanceladoEvent.cs
|   |-- Catalogo/
|       |-- Produto.cs
|
|-- ECommerce.Projections/
|   |-- PedidoProjector.cs
|   |-- CatalogoProjector.cs
|
|-- ECommerce.API/
    |-- Controllers/
        |-- PedidosCommandController.cs
        |-- PedidosQueryController.cs

Os Controllers

Percebe que até os controllers sao separados:

1// Controller de ESCRITA 2[ApiController] 3[Route("api/pedidos")] 4public class PedidosCommandController : ControllerBase 5{ 6 private readonly IMediator _mediator; 7 8 [HttpPost] 9 public async Task<IActionResult> Criar([FromBody] CriarPedidoCommand cmd) 10 { 11 await _mediator.Send(cmd); 12 return Accepted(); // 202 - o pedido ta sendo processado 13 } 14 15 [HttpPost("{id}/cancelar")] 16 public async Task<IActionResult> Cancelar(Guid id) 17 { 18 await _mediator.Send(new CancelarPedidoCommand { PedidoId = id }); 19 return Accepted(); 20 } 21} 22 23// Controller de LEITURA 24[ApiController] 25[Route("api/pedidos")] 26public class PedidosQueryController : ControllerBase 27{ 28 private readonly IMediator _mediator; 29 30 [HttpGet] 31 public async Task<IActionResult> Listar([FromQuery] ListarPedidosRequest request) 32 { 33 var result = await _mediator.Send(new ListarPedidosQuery(request)); 34 return Ok(result); 35 } 36 37 [HttpGet("{id}")] 38 public async Task<IActionResult> Detalhes(Guid id) 39 { 40 var result = await _mediator.Send(new DetalhesPedidoQuery { PedidoId = id }); 41 return Ok(result); 42 } 43}

O controller de escrita retorna 202 Accepted porque a operação pode ser assíncrona. O de leitura retorna 200 OK com os dados.

Pipeline Completo

O fluxo completo de criar um pedido:

  1. Cliente envia POST /api/pedidos com os dados
  2. Command Controller recebe e despacha CriarPedidoCommand
  3. Command Handler valida estoque, calcula preço, aplica regras
  4. Dominio cria a entidade Pedido e registra PedidoCriadoEvent
  5. Repository salva no banco de escrita
  6. Event Bus publica PedidoCriadoEvent
  7. Projector recebe o evento e atualiza pedidos_view no banco de leitura
  8. Query Controller pode agora retornar o pedido atualizado

Cada peça faz uma coisa. Cada peça pode ser testada isoladamente. Cada peça pode escalar independentemente.


Quando Usar CQRS?

CQRS não e bala de prata. Tem cenários onde faz total sentido e cenários onde é overengineering.

Faz sentido quando:

  • Leitura e escrita tem volumes muito diferentes. Ex: um e-commerce tem 100x mais leituras que escritas. Você pode escalar o lado de leitura separadamente.
  • Os modelos de leitura e escrita sao muito diferentes. Se a tela precisa de dados de 5 tabelas joinadas, ter um modelo de leitura desnormalizado resolve.
  • Você precisa de performance em leitura. Com modelos de leitura otimizados, você pode usar cache, bancos especializados, materialized views.
  • O domínio é complexo. Se o lado de escrita tem muitas regras, separar permite focar nelas sem poluir com concerns de exibição.
  • Você já usa Event Sourcing. CQRS é o parceiro natural de Event Sourcing.

Não faz sentido quando:

  • CRUD simples. Se sua aplicação é basicamente um formulário que salva e lista, CQRS é um canhão pra matar formiga.
  • Time pequeno sem experiência. CQRS adiciona complexidade. Se o time não entende os trade-offs, vai virar um problema.
  • Consistência imediata e obrigatória em tudo. Se você não pode aceitar nenhum delay entre escrita e leitura, CQRS com bancos separados vai te dar dor de cabeça.
  • Projeto pequeno e de curta duração. O overhead não compensa.

CQRS Sem Bancos Separados: O Meio Termo

Um ponto importante: CQRS não exige bancos separados. Você pode começar simples.

Nível 1: Separação lógica. Mesmo banco, mesmas tabelas, mas com modelos e handlers separados pra leitura e escrita. Já ganha muito em organização.

Nível 2: Views ou tabelas desnormalizadas. Mesmo banco, mas com materialized views ou tabelas de leitura. Ganha performance sem a complexidade de sincronização.

Nível 3: Bancos separados com eventos. O CQRS completo. Máximo de flexibilidade e escalabilidade, mas também máximo de complexidade.

A minha recomendação: comece pelo nível 1. Separe os modelos e handlers. Quando sentir que precisa de mais performance na leitura, evolua pro nível 2. Só va pro nível 3 quando realmente precisar.


Comparativo Rápido: Com e Sem CQRS

AspectoSem CQRSCom CQRS
ModelosUm modelo pra tudoModelos separados por responsabilidade
QueriesORM com joins complexosSQL otimizado em modelos flat
EscritaMistura regras com concerns de exibiçãoFocada puramente em regras de negócio
Performance de leituraLimitada pelo modelo de domínioOtimizada com desnormalização
ComplexidadeMenor no inícioMaior no início, menor a longo prazo
EscalabilidadeEscala tudo juntoEscala leitura e escrita independentemente
TestabilidadeTestes acopladosTestes isolados por lado

Erros Comuns ao Implementar CQRS

Pra fechar, alguns erros que eu vejo bastante:

  1. Aplicar CQRS em tudo. Você não precisa de CQRS no CRUD de cadastro de usuário. Aplique onde tem complexidade real.

  2. Ignorar o custo da eventual consistency. Se você não comunicar pro time e pro produto que "o dado pode levar alguns segundos pra aparecer", vai ter problema.

  3. Complicar demais a sincronização. Comece com algo simples. Um evento, um projetor, uma tabela de leitura. Não tente montar uma arquitetura de microservicos com Kafka no primeiro dia.

  4. Não monitorar a defasagem. Se o modelo de leitura tá 30 segundos atrasado, você precisa saber. Monitore o lag entre escrita e leitura.

  5. Achar que CQRS e Event Sourcing. CQRS é sobre separar modelos. Event Sourcing é sobre persistir eventos como fonte de verdade. Sao complementares, mas independentes.


Conclusão

CQRS é uma daquelas coisas que, quando você entende, você começa a enxergar onde faz sentido em todo sistema que você olha. A separação de leitura e escrita e tao natural que você se pergunta por que não pensou nisso antes.

Mas lembra: comece simples. Separe os modelos. Separe os handlers. Sinta a dor antes de trazer a complexidade. Quando a performance de leitura virar problema, você já vai ter a base pra evoluir sem reescrever tudo.

Racoelho

Conteúdo sobre desenvolvimento, tecnologia e desafios de programação para impulsionar sua carreira em tech.

Conecte-se

© 2024- 2026 Racoelho. Todos os direitos reservados.

v3.0.15 • Build: 2026-03-17