03 - Exemplo Prático (E-commerce, Banking)
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.