03 - Eventos de Domínio (Domain Events)
video: https://youtu.be/... no frontmatter da nota.Eventos de Domínio (Domain Events)
Um evento de domínio registra um fato relevante que já ocorreu no negócio e recebe nome no passado, como PedidoConfirmado, EstoqueReservado ou PagamentoRecusado. Ele nasce dentro do agregado, no momento em que as invariantes são satisfeitas com sucesso, e carrega os dados mínimos para descrever aquele fato. A relevância em produção vem do papel que exerce: é o mecanismo que permite a um módulo reagir a mudanças de outro sem acoplamento de chamada direta, e é a base de pipelines assíncronos, integrações entre contextos e arquiteturas orientadas a eventos. Sem eventos, os efeitos colaterais de uma operação terminam empilhados no mesmo método de aplicação, o que produz classes gigantes e deploys acoplados. Com eventos bem desenhados, cada consequência vira um handler independente, testável e escalável. O detalhe decisivo é que a mecânica interna, da coleta no agregado até a entrega confiável, determina se esse desenho sobrevive a falhas e a carga real.
Natureza do fato registrado
O evento é imutável por definição, porque descreve algo que aconteceu e não pode ser desfeito, apenas compensado por outro evento futuro. Ele se distingue de um comando, que expressa intenção e pode ser rejeitado; o evento já aconteceu e o consumidor não pode vetá-lo. A estrutura mais comum inclui identificador único, nome do evento, identificador do agregado de origem, carimbo de tempo e um payload com os dados do fato. O payload deve conter o que o consumidor precisa para decidir o que fazer, e nada mais; evento gordo demais vira contrato frágil, evento magro demais força consultas de volta à origem. A nomenclatura deve usar o vocabulário do negócio, pois o nome do evento é documentação viva lida em logs, dashboards, filas e discussões com a área de produto.
Em código, o evento normalmente é implementado como objeto de valor simples, sem comportamento, e um registro da lista de eventos pendentes fica exposto no agregado por meio de um método como obter_eventos. A decisão de representar eventos como classes fortes no código, e não como dicionários soltos, paga juros positivos: o compilador detecta handlers quebrados em refatorações, e a assinatura do evento serve de contrato interno. Em ecossistemas com vários serviços, esse padrão se estende ao schema público, mantido em IDL como Avro ou Protobuf, com registro de schemas centralizado.
O problema que resolvem
Considere a operação de confirmar um pedido. Depois da confirmação, o sistema precisa enviar e-mail ao cliente, reservar estoque, acionar faturamento e registrar métricas. A abordagem ingênua chama esses quatro módulos diretamente dentro do mesmo método, e três problemas aparecem de imediato. Primeiro, acoplamento: qualquer mudança no faturamento obriga alteração no fluxo do pedido, com rebuild e deploy conjuntos. Segundo, transação: ou todos os efeitos entram na mesma transação, esticando locks e criando falhas cruzadas, ou ficam fora dela, sem garantia de execução quando algo falha no meio. Terceiro, rastreabilidade: sem instrumentação extra, ninguém sabe quais efeitos foram disparados por qual operação. O evento de domínio resolve ao inverter a direção da dependência: o agregado apenas anuncia o fato, e cada módulo interessado se inscreve para reagir. O fluxo deixa de conhecer suas consequências e passa a publicar verdades.
A decisão de o que se torna evento costuma sair de uma sessão de modelagem com a área de negócio, em que cada verbo de mudança relevante vira candidato a fato registrado. O critério de corte é utilidade para terceiros: se nenhum módulo precisa reagir e nenhuma análise precisa daquele dado, o fato não vira evento, vira apenas detalhe interno do agregado. O inverso também vale: fatos que disparam discussões entre áreas, como pedido cancelado pelo cliente versus pedido cancelado por inadimplência, merecem eventos separados, porque consumidores reagem de forma diferente a cada um deles.
Funcionamento interno: coleta, commit e despacho
A mecânica correta tem três tempos distintos, e errar a ordem deles é a causa mais comum de bugs sutis. O agregado coleta os eventos em uma lista interna durante a execução da operação. A unidade de trabalho persiste o estado e, quando há outbox, persiste os eventos no mesmo commit. O despachante entrega os eventos aos handlers somente depois do commit bem-sucedido, nunca antes. Publicar antes do commit significa notificar consumidores de um fato que pode ser revertido, o que obriga a escrever compensações frágeis para desfazer e-mails enviados e estoques reservados.
class Pedido:
def __init__(self):
self._eventos = [] # eventos coletados durante a operação
def confirmar(self):
# invariantes verificadas antes de qualquer mutação de estado
if not self.itens:
raise ErroDeInvariante("pedido sem itens")
self.status = "confirmado"
self._eventos.append(PedidoConfirmado(
pedido_id=self.id, # fato no passado, nome no particípio
total=self.total(),
ocorrido_em=agora(),
))
def despachar_apos_commit(unidade_trabalho):
# entrega somente se a transação fechou com sucesso
for evento in unidade_trabalho.extrair_eventos():
for handler in registro_de_handlers[tipo(evento)]:
handler(evento) # handler idempotente por contrato
Em frameworks como Spring, o padrão aparece como afterCommit de TransactionSynchronization ou como interceptores de repositório que extraem a lista de eventos do agregado carregado. O ponto central é a separação de papéis: coletar é responsabilidade do agregado, persistir é responsabilidade da unidade de trabalho, entregar é responsabilidade do despachante. Essa separação permite testar cada papel isoladamente e trocar a entrega em processo por broker sem tocar no modelo.
No barramento em processo, a entrega síncrona dentro da mesma thread herda o contexto transacional, o que é conveniente, mas transforma a falha de um handler na falha da operação inteira. A entrega assíncrona por fila interna isola a falha, porém exige tratar ordenação e idempotência como no broker. Uma regra prática de produção: handlers que podem falhar por dependência externa, como envio de e-mail, não devem rodar sincronamente no caminho transacional, porque a indisponibilidade do provedor de e-mail derrubaria o checkout inteiro.
Entrega em processo versus entrega por broker
A entrega pode ocorrer dentro do próprio processo, com um barramento em memória, ou por um broker como Kafka, RabbitMQ ou Amazon SNS. A escolha define garantias, latência e custo operacional, e não existe opção superior em absoluto.
| Aspecto | Barramento em processo | Broker externo |
|---|---|---|
| Latência típica | microssegundos a milissegundos | milissegundos a dezenas de ms |
| Durabilidade | nenhuma, perde tudo em crash | persistida no broker com replicação |
| Escala horizontal | limitada ao processo único | consumidores distribuídos por partição |
| Atomicidade com o commit | simples de garantir | exige outbox ou padrão equivalente |
| Complexidade operacional | mínima | cluster, particionamento, monitoramento |
A leitura prática é direta: efeitos colaterais internos, como invalidar cache no mesmo serviço ou atualizar uma projeção local, funcionam bem em processo. Integrações entre serviços, notificações a terceiros e qualquer efeito que não pode ser perdido exigem broker com entrega persistente e reentrega. Um erro frequente é migrar tudo para broker de uma vez; em serviços pequenos, a fila em processo com fallback de log cobre a necessidade sem introduzir um cluster para operar.
A ordenação é o segundo fator de decisão, e o erro clássico é exigir ordem global no broker. Kafka e equivalentes garantem ordem apenas dentro de uma partição, então a partição deve ser chaveada pelo identificador do agregado; assim, eventos do mesmo pedido chegam em ordem, e eventos de pedidos distintos trafegam em paralelo. Pedir ordem global quando ela não é necessária serializa o consumo, cria hot partitions e limita o throughput sem ganho de correção. A pergunta certa em revisão de arquitetura é sempre a mesma: quais eventos realmente precisam de ordem entre si, e a resposta quase nunca é todos.
Padrão Outbox: persistir e publicar atomicamente
O problema da dupla escrita, gravar o estado no banco e publicar no broker, não tem solução por transação distribuída na maioria dos ambientes modernos. O padrão outbox resolve gravando o evento em uma tabela do mesmo banco, na mesma transação do estado, e um processo de retransmissão publica depois. O consumidor passa a receber pelo menos uma vez, o que torna a idempotência obrigatória, normalmente por deduplicação com o identificador do evento.
# Tabela outbox e ciclo de vida do registro
outbox:
colunas:
- id: "uuid do evento, base da deduplicação no consumidor"
- agregado_tipo: "Pedido"
- agregado_id: "identificador do agregado de origem"
- evento: "PedidoConfirmado"
- payload: "json com os dados do fato"
- status: "pendente, publicado, falhou"
retransmissao:
consulta: "status = pendente order by criado_em limit 100"
publicacao: "broker com confirmação de persistência"
atualizacao: "status = publicado apenas após ack do broker"
Em produção, monitore a defasagem entre criação e publicação do evento; crescimento contínuo dessa defasagem indica retransmissão saturada antes de qualquer sintoma visível no consumidor. Sem outbox, o comportamento típico em incidente é o pedido gravado sem confirmação publicada, um estado silencioso que só aparece na reclamação do cliente.
O desenho da tabela outbox influencia diretamente o throughput. Sem índice sobre status e criado_em, a consulta da retransmissão degrada conforme o histórico cresce; um índice parcial apenas sobre registros pendentes mantém a varredura pequena. O payload do evento deve estar pronto para publicar, já serializado no formato do broker, para que a retransmissão não precise conhecer regras de domínio. A limpeza dos registros publicados é parte do desenho, e não um detalhe: sem política de retenção, a tabela vira o maior objeto do banco em poucos meses de tráfego intenso.
Notificação versus evento de integração
Dentro de um contexto delimitado, o evento costuma ser uma notificação enxuta: anuncia o fato, e o consumidor decide se precisa buscar mais dados na origem. Entre contextos, o evento vira um contrato de integração, com schema versionado, compatibilidade retroativa e regime de mudança acordado entre times. A distinção importa porque muda quem pode alterar o quê: a notificação é detalhe interno que pode mudar em qualquer deploy, o contrato de integração pertence ao relacionamento entre serviços e quebra consumidores se for alterado sem cuidado.
| Característica | Notificação interna | Evento de integração |
|---|---|---|
| Payload | mínimo, apenas chaves e tipo | dados completos do caso de uso |
| Versionamento | informal, deploy único | schema versionado, compatibilidade obrigatória |
| Acoplamento | entre classes do mesmo serviço | entre contratos de serviços distintos |
| Falha típica | handler perdido em refatoração | consumidor quebrado por campo removido |
A evolução do evento de integração merece regime próprio. Campos novos devem ser opcionais para não quebrar consumidores antigos, campos nunca devem ser removidos ou renomeados sem janela de deprecação, e mudanças de tipo são mudanças de schema inteiras. Quando o formato precisa mudar de forma incompatível, o caminho seguro é publicar um evento com nome de versão, como PedidoConfirmadoV2, em janela de sobreposição até todos os consumidores migrarem. Consumidores que leem formatos antigos e novos ao mesmo tempo usam a técnica de upcasting, que converte o payload antigo para o novo no momento da leitura.
Aplicação prática em um fluxo de produção
Um fluxo real de e-commerce mostra a divisão de papéis em funcionamento. O agregado Pedido confirma e registra PedidoConfirmado; a unidade de trabalho grava o estado e a entrada na outbox; a retransmissão publica no broker. A partir daí, quatro consumidores reagem de forma independente: o serviço de estoque reserva itens, o de faturamento emite a nota, o de notificação envia o e-mail e o de analytics alimenta a projeção de vendas. Nenhum deles conhece o outro, e a queda do serviço de notificação em um feriado de pico não afeta faturamento nem estoque, apenas enfileira a reação afetada. O diagnóstico de incidente muda de natureza: em vez de seguir a pilha de chamadas de um método monolítico, a equipe consulta o trace correlacionado pelo identificador do evento e identifica exatamente qual consumidor parou.
Esse desenho também muda o custo de evoluir o negócio. Quando a área de fidelidade surge com a regra de conceder pontos após confirmação, a integração é apenas um novo consumidor de PedidoConfirmado, sem qualquer mudança no agregado Pedido ou nos consumidores existentes. O mesmo vale para a aposentadoria de uma integração: o consumidor é desligado e a publicação continua, sem risco de quebrar o fluxo principal. Essa assimetria, adicionar e remover reações sem tocar na origem, é o ganho estrutural que justifica o investimento na mecânica de entrega.
Testes e observabilidade
A separação de papéis sugere a divisão natural dos testes. O teste do agregado verifica as invariantes e a lista de eventos registrados, sem nenhuma infraestrutura. O teste do despachante valida ordem, entrega pós-commit e comportamento diante de handler que lança exceção. O teste de integração sobe o consumidor com broker real ou emulado e cobre idempotência, reentrega e Dead Letter Queue, que são os caminhos que falham em produção. Um teste que mistura agregado, despacho e consumidor no mesmo cenário cobre pouco, porque não isola a falha.
Na observação, três métricas separam um sistema de eventos saudável de um sistema doente. A defasagem de publicação mede o tempo entre criação na outbox e ack do broker, e deve ficar em milissegundos. A defasagem de consumo mede a idade das mensagens processadas, e picos dela antecedem reclamações de clientes. A taxa de mensagens na Dead Letter Queue, com a razão de falha por handler, orienta onde a equipe deve atuar primeiro; DLQ crescente é o equivalente funcional de um backlog de erros empilhados.
O log de eventos consumidos, com identificador da mensagem e do trace distribuído, é o instrumento que fecha o ciclo de diagnóstico. Sem ele, responder à pergunta se determinado pedido disparou os quatro efeitos esperados exige leitura de código; com ele, é uma consulta filtrada por agregado de origem. Times que padronizam essa correlação desde o primeiro consumidor evitam o retrabalho de instrumentar tudo depois do primeiro incidente de efeito perdido.
Erros comuns e boas práticas
Os erros recorrentes em produção seguem padrões identificáveis, e cada um tem mitigação conhecida.
| Sintoma em produção | Causa raiz | Mitigação |
|---|---|---|
| Consumidor reagiu a fato que não aconteceu | evento publicado antes do commit | despacho após commit ou outbox |
| Efeito executado duas vezes | reentrega do broker sem idempotência | deduplicação por id do evento |
| Fluxo impossível de rastrear em incidente | cadeia longa de eventos encadeados | limitar encadeamento, pipeline explícito |
| Dado sensível vazou em tópico público | payload com agregado inteiro | payload mínimo, dados por chave de acesso |
Além das mitigações da tabela, algumas práticas valem por si. Consumidores devem nascer idempotentes, tratando reentrega como caso normal e não como exceção. Toda fila precisa de Dead Letter Queue configurada, porque mensagem que falha repetidamente sem destino encadeia a partição inteira. O handler não deve executar lógica de negócio complexa; ele orquestra a reação e delega ao modelo local, mantendo o evento como gatilho e não como transportador de regra. A lista de eventos merece revisão periódica: eventos sem nenhum consumidor ativo devem ser removidos, pois cada publicação custa largura de banda, armazenamento e atenção operacional. Por fim, evite o excesso inverso, o evento para tudo; quando uma chamada direta resolve o caso sem acoplamento relevante, ela é mais simples de depurar e mais barata de operar.