CQRS na prática: separando leituras e escritas complexas

1. Fundamentos do CQRS e quando aplicá-lo

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

CQRS (Command Query Responsibility Segregation) foi popularizado por Greg Young e Udi Dahan como uma evolução do princípio CQS (Command Query Separation) proposto por Bertrand Meyer. Enquanto CQS opera no nível de métodos dentro de uma classe, CQRS eleva a separação ao nível arquitetural, criando modelos distintos para comandos (escritas) e consultas (leituras).

1.2. Diferença fundamental entre comandos e consultas

Comandos são operações que alteram o estado do sistema e não devem retornar dados de domínio. Consultas retornam dados e não devem alterar estado. Essa separação permite otimizar cada lado independentemente.

// Comando: não retorna dados de domínio
public record ProcessarPedidoCommand(
    Guid PedidoId,
    Guid ClienteId,
    List<ItemPedido> Itens,
    EnderecoEntrega Endereco
);

// Consulta: não altera estado
public record ObterDashboardQuery(
    Guid ClienteId,
    DateTime DataInicio,
    DateTime DataFim
);

1.3. Cenários ideais

CQRS brilha em sistemas com:
- Assimetria de carga entre leituras e escritas (ex: 90% consultas, 10% comandos)
- Regras de negócio complexas que exigem validações multi-domínio
- Múltiplas visões dos mesmos dados para diferentes contextos

2. Arquitetura de separação de modelos

2.1. Modelo de escrita

O modelo de escrita utiliza agregados DDD, garantindo consistência imediata através de validações invariantes.

public class Pedido : AggregateRoot
{
    private List<ItemPedido> _itens = new();
    public Guid Id { get; private set; }
    public StatusPedido Status { get; private set; }
    public decimal ValorTotal => _itens.Sum(i => i.Quantidade * i.PrecoUnitario);

    public void AdicionarItem(Produto produto, int quantidade)
    {
        if (quantidade <= 0)
            throw new DomainException("Quantidade deve ser positiva");

        if (produto.EstoqueDisponivel < quantidade)
            throw new DomainException("Estoque insuficiente");

        _itens.Add(new ItemPedido(produto.Id, quantidade, produto.Preco));
        AddDomainEvent(new ItemAdicionadoAoPedidoEvent(Id, produto.Id, quantidade));
    }
}

2.2. Modelo de leitura

O modelo de leitura é otimizado para consultas, utilizando denormalização e visões materializadas.

public class PedidoReadModel
{
    public Guid Id { get; set; }
    public string ClienteNome { get; set; }
    public string Status { get; set; }
    public decimal ValorTotal { get; set; }
    public int QuantidadeItens { get; set; }
    public DateTime DataCriacao { get; set; }
    public DateTime? DataPagamento { get; set; }
}

2.3. Handlers separados

// Command Handler
public class ProcessarPedidoHandler : ICommandHandler<ProcessarPedidoCommand>
{
    private readonly IPedidoRepository _repository;
    private readonly IEventBus _eventBus;

    public async Task Handle(ProcessarPedidoCommand command, CancellationToken ct)
    {
        var pedido = new Pedido(command.ClienteId);
        foreach (var item in command.Itens)
            pedido.AdicionarItem(item.Produto, item.Quantidade);

        await _repository.SaveAsync(pedido, ct);
        await _eventBus.PublishAsync(new PedidoProcessadoEvent(pedido.Id), ct);
    }
}

// Query Handler
public class ObterDashboardHandler : IQueryHandler<ObterDashboardQuery, DashboardDto>
{
    private readonly IDashboardReadRepository _repository;

    public async Task<DashboardDto> Handle(ObterDashboardQuery query, CancellationToken ct)
    {
        return await _repository.ObterDashboardAsync(
            query.ClienteId, query.DataInicio, query.DataFim, ct);
    }
}

3. Sincronização entre modelos: eventos e projeções

3.1. Event Sourcing como fonte única de verdade

Event Sourcing armazena o estado como uma sequência de eventos, permitindo reconstruir qualquer estado anterior e alimentar projeções de leitura.

public class PedidoEventStore
{
    private readonly List<IEvent> _events = new();

    public void Append(IEvent @event)
    {
        _events.Add(@event);
        // Persistir em banco de eventos
    }

    public IEnumerable<IEvent> GetEvents(Guid aggregateId)
    {
        return _events.Where(e => e.AggregateId == aggregateId);
    }
}

3.2. Projeções assíncronas

public class PedidoProjection : IEventHandler<PedidoProcessadoEvent>
{
    private readonly IPedidoReadRepository _readRepo;

    public async Task Handle(PedidoProcessadoEvent @event, CancellationToken ct)
    {
        var readModel = new PedidoReadModel
        {
            Id = @event.PedidoId,
            Status = "Processado",
            DataCriacao = DateTime.UtcNow,
            ValorTotal = @event.ValorTotal,
            QuantidadeItens = @event.Itens.Count
        };

        await _readRepo.UpsertAsync(readModel, ct);
    }
}

3.3. Consistência eventual

Estratégias para lidar com dados obsoletos:
- Timestamp de última atualização no modelo de leitura
- Versionamento otimista para detecção de conflitos
- Notificações ao usuário sobre dados "quase em tempo real"

4. Implementação prática de comandos complexos

4.1. Processamento de pedido multi-domínio

public class ProcessarPedidoCompletoHandler : ICommandHandler<ProcessarPedidoCompletoCommand>
{
    private readonly IPedidoRepository _pedidoRepo;
    private readonly IEstoqueRepository _estoqueRepo;
    private readonly IFaturamentoService _faturamentoService;
    private readonly IUnitOfWork _uow;

    public async Task Handle(ProcessarPedidoCompletoCommand command, CancellationToken ct)
    {
        using var transaction = await _uow.BeginTransactionAsync(ct);
        try
        {
            var pedido = new Pedido(command.ClienteId);
            // Validações multi-domínio
            foreach (var item in command.Itens)
            {
                var produto = await _estoqueRepo.ObterPorIdAsync(item.ProdutoId, ct);
                if (produto == null || produto.EstoqueDisponivel < item.Quantidade)
                    throw new DomainException($"Estoque insuficiente para {item.ProdutoId}");

                pedido.AdicionarItem(produto, item.Quantidade);
                produto.DiminuirEstoque(item.Quantidade);
                await _estoqueRepo.AtualizarAsync(produto, ct);
            }

            await _pedidoRepo.SaveAsync(pedido, ct);
            await _faturamentoService.CriarFaturaAsync(pedido, ct);
            await _uow.CommitAsync(ct);
        }
        catch
        {
            await _uow.RollbackAsync(ct);
            throw;
        }
    }
}

4.2. Idempotência em comandos

public class IdempotentCommandHandler<T> : ICommandHandler<T> where T : ICommand
{
    private readonly ICommandHandler<T> _inner;
    private readonly IIdempotencyStore _store;

    public async Task Handle(T command, CancellationToken ct)
    {
        var key = command.GetIdempotencyKey();
        if (await _store.ExistsAsync(key, ct))
            return; // Comando já processado

        await _inner.Handle(command, ct);
        await _store.MarkAsProcessedAsync(key, ct);
    }
}

5. Implementação prática de consultas complexas

5.1. Dashboard agregado multi-bounded context

public class DashboardReadRepository : IDashboardReadRepository
{
    private readonly DbContext _db;

    public async Task<DashboardDto> ObterDashboardAsync(
        Guid clienteId, DateTime inicio, DateTime fim, CancellationToken ct)
    {
        var query = from p in _db.PedidosReadModel
                    join f in _db.FaturamentoReadModel on p.Id equals f.PedidoId
                    join e in _db.EntregasReadModel on p.Id equals e.PedidoId
                    where p.ClienteId == clienteId
                          && p.DataCriacao >= inicio && p.DataCriacao <= fim
                    select new DashboardDto
                    {
                        TotalPedidos = p.Count,
                        ValorTotalFaturado = f.ValorTotal,
                        StatusEntrega = e.Status,
                        MediaItensPorPedido = p.QuantidadeItens / p.Count
                    };

        return await query.FirstOrDefaultAsync(ct);
    }
}

5.2. Visões materializadas

-- SQL para criar visão materializada
CREATE MATERIALIZED VIEW dashboard_mensal AS
SELECT 
    cliente_id,
    DATE_TRUNC('month', data_criacao) AS mes,
    COUNT(*) AS total_pedidos,
    SUM(valor_total) AS receita_total,
    AVG(quantidade_itens) AS media_itens
FROM pedidos_read_model
GROUP BY cliente_id, DATE_TRUNC('month', data_criacao);

6. Integração com microsserviços

6.1. Comunicação via mensageria

// Publicação de comando via Kafka
public class KafkaCommandBus : ICommandBus
{
    private readonly IProducer<string, string> _producer;

    public async Task SendAsync<T>(T command, CancellationToken ct) where T : ICommand
    {
        var message = new Message<string, string>
        {
            Key = command.GetType().Name,
            Value = JsonSerializer.Serialize(command)
        };
        await _producer.ProduceAsync("commands-topic", message, ct);
    }
}

6.2. Observabilidade com service mesh

O service mesh (Istio/Linkerd) adiciona:
- Roteamento inteligente entre command handlers e query handlers
- Circuit breakers para comandos com alta latência
- Métricas de sucesso/falha por tipo de operação

6.3. Sagas para orquestração

public class PedidoSaga : Saga<PedidoSagaData>
{
    public async Task ProcessarPedido(ProcessarPedidoCommand command)
    {
        await SendCommand(new ReservarEstoqueCommand(command.Itens));
        await SendCommand(new ProcessarPagamentoCommand(command.ClienteId, command.Valor));
        await SendCommand(new CriarNotaFiscalCommand(command.PedidoId));
    }

    public async Task Compensar(ProcessarPedidoCommand command)
    {
        await SendCommand(new CancelarReservaEstoqueCommand(command.Itens));
        await SendCommand(new EstornarPagamentoCommand(command.ClienteId, command.Valor));
    }
}

7. Desafios, armadilhas e boas práticas

7.1. Complexidade adicional

  • Manter dois modelos aumenta o esforço de desenvolvimento
  • Sincronização eventual pode causar inconsistências temporárias
  • Debugging mais complexo com fluxos assíncronos

7.2. Quando evitar CQRS

  • Sistemas CRUD simples sem regras de negócio complexas
  • Requisitos de latência extremamente baixa (< 10ms)
  • Equipes pequenas sem experiência em arquiteturas distribuídas

7.3. Padrões complementares

  • DDD + Bounded Contexts: delimita claramente os limites de cada modelo
  • Event Sourcing: fornece a fonte única de verdade para projeções
  • Eventual Consistency: aceita que leituras podem estar ligeiramente desatualizadas

Referências