Como aplicar o padrão CQRS em aplicações complexas

1. Introdução ao CQRS e seu papel em sistemas complexos

1.1. Definição do padrão Command Query Responsibility Segregation

O padrão CQRS (Command Query Responsibility Segregation) propõe a separação explícita entre operações que modificam o estado do sistema (Commands) e operações que apenas leem dados (Queries). Em vez de usar um único modelo de dados para leitura e escrita, como ocorre em arquiteturas tradicionais, o CQRS sugere modelos distintos e otimizados para cada finalidade.

1.2. Diferença entre CQRS e arquiteturas tradicionais (CRUD)

Em sistemas CRUD convencionais, uma única entidade (ex.: Pedido) serve tanto para criar, atualizar, ler e deletar registros. Isso funciona bem para aplicações simples, mas em sistemas complexos com alta concorrência e regras de negócio intricadas, esse modelo único gera gargalos de desempenho e acoplamento indesejado. O CQRS desacopla essas responsabilidades, permitindo que cada lado evolua de forma independente.

1.3. Cenários onde CQRS é indicado

O padrão é especialmente útil em:
- Alta concorrência: quando múltiplos usuários disputam a mesma entidade simultaneamente.
- Domínios complexos: onde as regras de validação para escrita são muito diferentes das necessidades de leitura.
- Escalabilidade: quando leitura e escrita precisam de escalonamento independente (ex.: leitura com cache, escrita com filas).

2. Componentes fundamentais do padrão CQRS

2.1. Comandos (Commands)

Comandos representam intenções de modificar o estado. Eles são objetos imutáveis que carregam dados necessários para executar uma ação. Exemplo:

Command: CriarPedido
  - clienteId: string
  - itens: Item[]
  - dataCriacao: DateTime

Cada comando é processado por um Command Handler que valida regras de negócio, persiste os dados e, opcionalmente, dispara eventos.

2.2. Consultas (Queries)

Consultas são operações de leitura que retornam dados sem efeitos colaterais. Elas são otimizadas para performance, podendo usar projeções desnormalizadas, caches ou bancos de dados separados.

Query: ObterResumoPedido
  - pedidoId: string
Retorno:
  - clienteNome: string
  - total: decimal
  - status: string

2.3. Modelos separados

  • Write Model: otimizado para validação e consistência transacional (normalizado, com integridade referencial).
  • Read Model: otimizado para consultas rápidas (desnormalizado, com dados pré-calculados).

3. Estratégias de implementação de comandos e eventos

3.1. Design de comandos como objetos imutáveis

Todo comando deve ser imutável após criado. Handlers são funções puras que recebem o comando e retornam eventos ou resultados.

// Exemplo de handler para CriarPedido
handle(CriarPedido comando):
  validarCliente(comando.clienteId)
  validarItens(comando.itens)
  pedido = new Pedido(comando.clienteId, comando.itens)
  salvar(pedido)
  publicarEvento(PedidoCriado(pedido.id, comando.clienteId))

3.2. Uso de filas de mensagens e event sourcing

Para garantir durabilidade e rastreabilidade, eventos podem ser armazenados em um Event Store. Filas como RabbitMQ ou Kafka permitem processamento assíncrono.

// Evento armazenado
Event: PedidoCriado
  - eventId: guid
  - pedidoId: string
  - clienteId: string
  - timestamp: DateTime

3.3. Tratamento de concorrência e consistência eventual

Em sistemas distribuídos, a consistência eventual é aceita. Use versionamento otimista (ex.: campo version no write model) para evitar conflitos.

4. Projeções e sincronização entre modelos

4.1. Criação de projeções a partir de eventos

Projeções transformam eventos em dados de leitura. Por exemplo, a partir do evento PedidoCriado, atualizamos a tabela resumo_pedidos com dados desnormalizados.

4.2. Estratégias de atualização

  • Síncrona: atualização imediata do read model (mais consistente, porém mais lenta).
  • Assíncrona: atualização via fila (menos consistente, porém mais escalável).

4.3. Cache e otimização de consultas

Use Redis ou Memcached para armazenar projeções frequentemente acessadas. Exemplo:

// Consulta com cache
obterResumoPedido(pedidoId):
  if cache.exists(pedidoId):
    return cache.get(pedidoId)
  else:
    dados = readModel.buscar(pedidoId)
    cache.set(pedidoId, dados, ttl=300)
    return dados

5. Exemplo prático: aplicação de CQRS em um sistema de pedidos

5.1. Definição dos comandos

Command: CriarPedido
  - clienteId: string
  - itens: [{ produtoId, quantidade, precoUnitario }]

Command: AdicionarItem
  - pedidoId: string
  - produtoId: string
  - quantidade: int

Command: FinalizarPedido
  - pedidoId: string

5.2. Definição das consultas

Query: ObterResumoPedido
  - pedidoId: string
Retorno: { cliente, total, status, itens }

Query: ListarPedidosPorCliente
  - clienteId: string
  - pagina: int
Retorno: [ { pedidoId, data, total, status } ]

5.3. Implementação de handlers e projeções

// Handler para CriarPedido
handle(CriarPedido cmd):
  validarCliente(cmd.clienteId)
  pedido = new Pedido(cmd.clienteId, cmd.itens)
  eventStore.salvar(PedidoCriado(pedido.id, cmd))
  fila.publicar(PedidoCriado(pedido.id))

// Projeção assíncrona
aoReceber(PedidoCriado evento):
  readModel.inserir({
    pedidoId: evento.pedidoId,
    clienteNome: db.clientes.buscar(evento.clienteId).nome,
    total: calcularTotal(evento.itens),
    status: "criado"
  })

6. Desafios e boas práticas na adoção do CQRS

6.1. Gerenciamento da complexidade adicional

CQRS aumenta a complexidade operacional: dois modelos, filas, event store. Só adote se os benefícios superarem os custos. Comece com uma parte do sistema (ex.: módulo de pedidos).

6.2. Estratégias para lidar com inconsistências temporárias

  • Use Sagas ou Process Managers para coordenar fluxos longos.
  • Implemente compensação para reverter ações em caso de falha.

6.3. Testabilidade e monitoramento

  • Teste comandos isoladamente com mocks do event store.
  • Monitore a latência entre a publicação do evento e a atualização da projeção.
  • Use logs estruturados para rastrear o fluxo completo.

7. Integração do CQRS com outros padrões arquiteturais

7.1. Combinação com Event Sourcing

Event Sourcing armazena o estado como uma sequência de eventos. CQRS + Event Sourcing fornece auditoria completa e capacidade de reconstruir o estado em qualquer ponto no tempo.

7.2. Uso com microsserviços e mensageria distribuída

Cada microsserviço pode ter seu próprio modelo de escrita e leitura. Kafka ou RabbitMQ servem como barramento de eventos entre serviços.

7.3. Sinergia com Clean Architecture

A separação de responsabilidades do CQRS alinha-se naturalmente com os princípios da Clean Architecture: dependências apontam para dentro, casos de uso (comandos/consultas) são centrais.

8. Conclusão e próximos passos

8.1. Resumo dos benefícios e trade-offs

Benefícios: escalabilidade independente, modelos otimizados, rastreabilidade, desacoplamento.
Trade-offs: complexidade adicional, consistência eventual, custo de infraestrutura.

8.2. Critérios para adoção

Adote CQRS quando:
- O modelo de leitura for significativamente diferente do modelo de escrita.
- A aplicação exigir escalabilidade separada para leitura e escrita.
- O domínio for complexo e exigir auditoria completa.

8.3. Referências para aprofundamento

  • Implementing Domain-Driven Design (Vaughn Vernon)
  • Patterns, Principles, and Practices of Domain-Driven Design (Scott Millett)
  • Microsoft docs sobre CQRS
  • Artigos de Martin Fowler sobre CQRS e Event Sourcing
  • Frameworks: Axon Framework (Java), MediatR (.NET)

Referências