Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

03 - Exemplo Prático (E-commerce, Banking)

#banking#ecommerce#exemplo-pratico#projecoes#snapshot
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione video: https://youtu.be/... no frontmatter da nota.

Exemplo Prático: E-commerce e Banking

Exemplos concretos transformam padrões abstratos em decisões reconhecíveis. Este arquivo percorre dois cenários clássicos do par Event Sourcing e CQRS: o fluxo de um pedido em e-commerce, com comandos, eventos, projeções, cancelamento e reembolso, e uma conta bancária com Event Sourcing puro, incluindo snapshots para otimizar a carga. O primeiro cenário usa a combinação completa, porque pedido vende leitura em muitas formas e exige histórico auditável. O segundo mostra o padrão em sua forma mais pura, onde invariantes financeiras e longevidade da entidade dominam o desenho. Os dois juntos cobrem as decisões mais frequentes que um time enfrenta ao aplicar os conceitos do módulo em um sistema real.

O problema que os exemplos ilustram

A dificuldade recorrente nos dois domínios é a mesma: onde cada garantia mora. Regras de negócio precisam de um lugar transacional e defensável, leituras precisam de lugares rápidos e variados, e correções de erro precisam de caminho que não destrua o histórico. Modelos de tabela única misturam as três responsabilidades e entregam mal todas. Os exemplos a seguir mostram como comandos, eventos, projeções e snapshots dividem esse território com fronteiras claras.

Cenário 1: o fluxo de um pedido

O pedido começa como agregado criado pelo comando AdicionarItemAoPedido e evolui por comandos do cliente e do sistema. A sequência típica tem AdicionarItem, RemoverItem, FecharPedido, PagarPedido, SepararEstoque, DespacharPedido e EntregarPedido, cada um validando invariantes no agregado e gerando fatos. Os eventos seguem o particípio ou o passado: ItemAdicionado, PedidoFechado, PedidoPago, PedidoDespachado. A nomenclatura importa porque eventos são contratos públicos consumidos por projeções e por outros sistemas, e o verbo no passado registra o que aconteceu, e não o que se pretendia.

Comandos e seus contratos no fluxo:

  • AdicionarItemAoPedido: valida catálogo e estoque reservável, e só cria o item se o pedido estiver aberto.
  • RemoverItemDoPedido: aceito apenas enquanto o pedido não foi fechado.
  • FecharPedido: calcula totais, exige endereço e encerra a fase de inclusões.
  • PagarPedido: exige pedido fechado, não pago, e produz o fato PedidoPago, que abre o fluxo de separação.
  • CancelarPedido: só é aceito antes do despacho, e declara se há estorno pendente.
  • ReembolsarPedido: após pagamento confirmado, registra o reembolso como fato próprio.

Eventos correspondentes: ItemAdicionado, ItemRemovido, PedidoFechado, PedidoPago, PedidoCancelado, ReembolsoIniciado, ReembolsoConcluido, PedidoDespachado e PedidoEntregue. Cada um carrega apenas o que o fato determina: valores, skus, quantidades, motivo quando existe, e metadados de correlação como correlationId e causationId.

Comandos, eventos e handler do pedido

// Comandos: intenções no imperativo, sem estado de resposta de negócio
public record FecharPedido(String pedidoId, EnderecoDto endereco) {}
public record PagarPedido(String pedidoId, String formaPagamento, BigDecimal valor) {}
public record CancelarPedido(String pedidoId, String motivo) {}

// Eventos: fatos no passado, com os dados que o fato determina
public record PedidoFechado(String pedidoId, BigDecimal total, String fechadoEm) {}
public record PedidoPago(String pedidoId, String formaPagamento, BigDecimal valor) {}
public record PedidoCancelado(String pedidoId, String motivo, boolean comEstorno) {}

// Handler: carrega por replay, valida invariantes e anexa eventos
public class PagarPedidoHandler {
    public void executar(PagarPedido comando) {
        Pedido pedido = repositorio.carregar("pedido-" + comando.pedidoId());
        if (!pedido.estaFechado()) {
            throw new RegraViolada("Pedido aberto não aceita pagamento");
        }
        if (pedido.estaPago()) {
            throw new RegraViolada("Pedido já pago");   // idempotência no agregado
        }
        pedido.registrarPagamento(comando.formaPagamento(), comando.valor());
        eventStore.acrescentar(
            "pedido-" + comando.pedidoId(),
            pedido.versaoEsperada(),     // concorrência otimista no stream
            pedido.eventosPendentes());  // PedidoPago sai daqui para as projeções
    }
}

O handler nunca atualiza tabela de status nem chama a fila do armazém. Sua responsabilidade termina no append confirmado; todo efeito posterior nasce da assinatura dos eventos. Essa restrição é o que garante que nenhuma visão fique sem rebroadcast: quem perdeu o momento, repassa pelo log.

Projeções do pedido

O mesmo conjunto de eventos alimenta visões diferentes, cada uma desenhada para um consumidor. A tabela resume as projeções típicas do fluxo.

Projeção Consumidor Conteúdo Eventos que alimentam
ResumoDoPedido Tela do cliente Status, itens, total, datas ItemAdicionado, PedidoPago, PedidoCancelado, PedidoEntregue
FilaDeSeparacao Armazém Itens, endereço, prioridade PedidoPago, PedidoCancelado
ExtratoFinanceiro Financeiro Valores, forma de pagamento, estornos PedidoPago, ReembolsoConcluido
VendasPorDia Comercial Contagem e soma por data PedidoPago, ReembolsoConcluido
// Projetor da FilaDeSeparacao: entra na fila quando pago, sai quando cancelado
public class FilaDeSeparacaoProjecao {
    public void processar(EventoEnvolto envolto) {
        if (envolto.tipo().equals("PedidoPago")) {
            var dados = envolto.dados();
            filaDao.inserir(new ItemDeSeparacao(
                dados.pedidoId(),
                itensDao.buscarItens(dados.pedidoId()),
                dados.enderecoEntrega()));
        }
        if (envolto.tipo().equals("PedidoCancelado")) {
            filaDao.remover(envolto.dados().pedidoId());   // saída da fila
        }
        checkpoint.gravar(envolto.posicaoGlobal());        // idempotência
    }
}

A defasagem entre o pagamento confirmado e a aparição na fila de separação é consistência eventual no estado puro, e é tratada com os padrões do módulo: lag medido por projeção, pendência visível na tela do cliente e automação de armazém lendo com exigência de frescor quando necessário.

Cancelamento e reembolso: compensação pelo log

Cancelamento é um comando que valida o estado corrente do agregado: só entra antes do despacho, e declara estorno quando o pagamento já aconteceu. O histórico permanece íntegro, porque a correção é aditiva: PedidoPago e PedidoCancelado coexistem no stream, e nenhum registro é editado ou apagado. O reembolso em si é executado por um processo que reage ao fato, conversa com o provedor de pagamento e devolve ReembolsoConcluido ao log, fechando o ciclo de compensação com eventos, e não com updates silenciosos.

// Cancelamento valida estado corrente e publica fatos de compensação
public class CancelarPedidoHandler {
    public void executar(CancelarPedido comando) {
        Pedido pedido = repositorio.carregar("pedido-" + comando.pedidoId());
        if (pedido.foiDespachado()) {
            throw new RegraViolada("Pedido despachado não pode ser cancelado");
        }
        pedido.cancelar(comando.motivo());  // gera PedidoCancelado com comEstorno se pago
        eventStore.acrescentar("pedido-" + comando.pedidoId(),
            pedido.versaoEsperada(), pedido.eventosPendentes());
        // o estorno é executado por um processo que reage a PedidoCancelado
    }
}

As projeções reagem ao fato de compensação de forma independente: ResumoDoPedido exibe cancelado, FilaDeSeparacao remove o item, ExtratoFinanceiro aguarda ReembolsoConcluido para registrar o crédito, e VendasPorDia decide política própria, mantendo a venda e deduzindo o estorno. Nenhuma delas consulta o agregado; todas consomem o mesmo log em ritmos próprios.

Cenário 2: conta bancária com Event Sourcing puro

A conta bancária é o exemplo canônico do Event Sourcing puro: o saldo não é gravado, ele é derivado dos eventos. AberturaDeConta, DepositoRealizado, SaqueRealizado, TransferenciaEnviada e TransferenciaRecebida formam o extrato e, ao mesmo tempo, a fonte do estado. A invariante central, o saldo não pode ficar negativo, é avaliada sobre o estado reidratado no momento do comando, e a versão esperada garante que dois saques concorrentes não passem juntos pela checagem com o mesmo saldo inicial.

// Agregado Conta: estado derivado dos eventos por funções de aplicação
public class Conta {
    private String id;
    private BigDecimal saldo = BigDecimal.ZERO;
    private long versaoEsperada;
    private List<Object> pendentes = new ArrayList<>();

    // Funções de aplicação: mutam o estado a partir de fatos
    private void aoAplicar(DepositoRealizado e) { saldo = saldo.add(e.valor()); }
    private void aoAplicar(SaqueRealizado e)    { saldo = saldo.subtract(e.valor()); }

    // Decisão de comando: valida invariante sobre o estado e gera fato
    public void sacar(BigDecimal valor) {
        if (saldo.subtract(valor).compareTo(BigDecimal.ZERO) < 0) {
            throw new RegraViolada("Saldo insuficiente");
        }
        pendentes.add(new SaqueRealizado(id, valor));
    }

    public void depositar(BigDecimal valor) {
        pendentes.add(new DepositoRealizado(id, valor));
    }
}

A leitura nesse cenário é mínima: o extrato é a própria lista de eventos do stream, lida em ordem, e o saldo corrente pode vir da reidratação do agregado no caminho de comando. Não há telas com formas de consulta variadas, então o lado de leitura estruturado do CQRS não é necessário; quando consultas analíticas crescerem, projeções entram como no cenário anterior, sem mudar o núcleo.

Snapshot na conta bancária

Contas vivem por décadas e movimentam milhares de eventos; reidratar tudo a cada saque degrada a latência dos comandos nos agregados mais valiosos. O snapshot congela saldo, status e versão a cada N eventos, e a carga aplica apenas a cauda posterior à foto.

// Carga otimizada: snapshot + cauda de eventos após a foto
public Conta carregar(String contaId) {
    Snapshot foto = snapshotStore.buscarMaisRecente("conta-" + contaId);
    long inicio = (foto != null) ? foto.versao() + 1 : 1;
    Conta conta = (foto != null) ? Conta.doSnapshot(foto) : new Conta(contaId);

    eventStore.lerStreamAPartirDe("conta-" + contaId, inicio)
              .forEach(e -> conta.aplicar(e));
    return conta;
}

// Gravação: eventos confirmados primeiro, foto depois, sem transação comum
public void salvar(Conta conta) {
    long novaVersao = eventStore.acrescentar(
        "conta-" + conta.id(), conta.versaoEsperada(), conta.eventosPendentes());
    if (novaVersao % INTERVALO_SNAPSHOT == 0) {   // por exemplo, a cada 100 eventos
        snapshotStore.gravar(conta.paraSnapshot(novaVersao));
    }
}

A foto é descartável por definição: se ficar defasada, corrompida ou com formato antigo, o replay recria o estado, porque o log é a única verdade. A gravação do snapshot nunca derruba o comando de negócio; sua falha é logada e a próxima carga paga o custo maior uma única vez.

Comparação entre os dois cenários

Aspecto Pedido em e-commerce Conta bancária
Arranjo ES + CQRS completos ES puro com snapshot
Motivo da adoção Leitura em muitas formas e integração Invariantes financeiras e histórico legal
Leitura dominante Projeções dedicadas por tela Extrato pelo stream e saldo reidratado
Compensação Cancelamento e reembolso como fatos Estorno como fato no próprio stream
Eventos por entidade Centenas em anos de vida Milhares em anos de vida
Otimização de carga Raramente necessária Snapshot com intervalo curto
Defasagem tratada Sim, com lag por projeção Quase inexistente, leitura mínima

A lição da comparação é que o desenho segue o perfil de leitura, e não a moda. O pedido precisa de visões porque é consultado de muitas formas; a conta prescinde delas porque sua leitura natural é o próprio histórico. Os dois compartilham o núcleo: agregado transacional, append com versão esperada e correção por eventos de compensação.

Erros comuns e boas práticas

  • Nomear eventos como comandos; o fato registra o passado no particípio, e o imperativo fica nos comandos.
  • Gravar saldo dentro do evento de saque; o saldo é derivado, e gravá-lo congela uma regra que pode mudar.
  • Cancelar editando registros; toda correção no e-commerce entra como evento de compensação, mantendo o histórico íntegro.
  • Esquecer idempotência nas projeções; a reentrega do PedidoPago não pode inserir o item de separação duas vezes.
  • Aplicar snapshot por moda; meça a reidratação da conta antes de introduzir a otimização, e por tipo de agregado.
  • Tratar o reembolso como efeito colateral silencioso; ele é fato público, consumido por extrato, comercial e suporte.
  • Consultar a visão para decidir no comando; a decisão usa o agregado, e a visão eventual não é fonte de verdade.
  • Misturar eventos de pedido e de pagamento no mesmo stream; cada agregado tem seu stream, e a fronteira é clara.

Práticas que sustentam os dois cenários: mantenha os eventos no vocabulário do domínio, porque extrato e auditoria são lidos por humanos; teste invariantes com concorrência simulada, provando que a versão esperada recusa o segundo saque; monitore o lag das projeções do pedido como número de primeira classe; e ensaie a restauração do store com replay até snapshot mais cauda, porque no banking o log é documento legal, e não apenas estrutura técnica.