Trilha 4 · Escala e evolução · ~32 min · pleno/sênior
O bug mais caro que já investiguei tinha uma linha de código como culpada. Um serviço de pagamentos fazia isto:
await this.repo.save(payment); // 1. grava o pagamento no banco
await this.eventBus.publish(new PaymentConfirmed(id)); // 2. avisa os outros módulos
Parece óbvio e correto. E funcionava 99,9% das vezes. Mas naquele 0,1% — quando o processo caía entre a linha 1 e a linha 2, ou quando o broker de mensagens estava momentaneamente indisponível — o pagamento ficava gravado no banco, mas o evento nunca era publicado. Resultado: o cliente pagava (billing tinha o registro), mas o acesso ao conteúdo nunca era liberado (content nunca recebeu o PaymentConfirmed). Dinheiro cobrado, serviço não entregue. Multiplicado por milhares de transações, viraram centenas de clientes lesados por semana, um problema de suporte gigante e risco regulatório.
A causa-raiz é sutil e universal: gravar no banco e publicar um evento são duas operações em dois sistemas diferentes (o banco e o broker), e não há uma transação que cubra os dois. Ou você garante os dois juntos, ou aceita que às vezes um acontece sem o outro. Esta aula é sobre as três técnicas que tornam arquitetura orientada a eventos confiável em produção: o Transactional Outbox (resolve exatamente esse bug), a idempotência (lida com a duplicação que a solução do Outbox introduz) e as sagas (coordenam processos de negócio que atravessam módulos sem uma transação global). Sem elas, event-driven é uma bomba-relógio elegante.
O dual-write problem e o Transactional Outbox
O bug da abertura tem nome: dual-write problem. Você precisa escrever em dois lugares (banco + broker) de forma consistente, mas não existe transação abrangendo os dois. Quatro cenários de falha:
- Salva no banco ✅, publica o evento ✅ → ok (o caso comum).
- Salva no banco ✅, cai antes de publicar → estado gravado, ninguém avisado (o bug).
- Salva no banco ✅, broker indisponível na hora de publicar → idem.
- Publica o evento ✅, falha ao salvar no banco → outros reagem a um fato que não aconteceu (pior ainda).
A solução elegante e comprovada é o Transactional Outbox: em vez de publicar no broker durante a transação, você grava o evento numa tabela do próprio banco (outbox), na mesma transação da escrita de negócio. Como é a mesma transação, ou os dois são gravados, ou nenhum — atomicidade garantida pelo banco. Depois, um processo separado lê a tabela outbox e publica no broker, marcando como publicado.
// ✅ passo 1: gravar negócio + evento na MESMA transação (atômico)
@Transactional()
async confirmPayment(paymentId: string) {
const payment = await this.payments.markConfirmed(paymentId); // escrita de negócio
await this.outbox.save({ // MESMA transação
id: randomUUID(),
type: 'PaymentConfirmed',
payload: { paymentId, userId: payment.userId },
occurredAt: new Date(),
publishedAt: null, // ainda não publicado
});
} // COMMIT: negócio e evento juntos, ou nada
// ✅ passo 2: um relay separado publica o que está na outbox e marca como enviado
@Injectable()
export class OutboxRelay {
@Interval(1000) // roda periodicamente (ou via trigger/CDC)
async publishPending() {
const pending = await this.outbox.findUnpublished(100);
for (const msg of pending) {
await this.broker.publish(msg.type, msg.payload); // publica no broker
await this.outbox.markPublished(msg.id); // marca publicado
}
}
}
Agora o bug da abertura é impossível: se o processo cair depois do commit da transação de negócio, o evento já está na outbox e o relay o publicará assim que voltar. Se o broker estiver fora, o relay tenta de novo no próximo ciclo. O evento nunca se perde, porque sua existência está atada à mesma transação do fato que ele anuncia.
A ideia-chave: transforme o "dois sistemas" (banco + broker) em "um sistema" (só o banco) no momento crítico. O evento vira um dado transacional como qualquer outro; a publicação vira um passo assíncrono e retryável depois. Você troca "publicação imediata mas não garantida" por "publicação levemente atrasada mas garantida" — quase sempre o trade certo.
Uma variação usa Change Data Capture (CDC) (ex.: Debezium lendo o log de transações do Postgres) em vez de um relay que faz polling — mais eficiente em escala, mesma ideia. E o mecanismo do relay/outbox é infraestrutura reutilizável (aula Persistência e State Isolation), não domínio.
Garantias de entrega: por que at-least-once + idempotência
O Outbox garante que o evento será publicado, mas introduz uma consequência inevitável: ele pode ser publicado mais de uma vez. Cenário: o relay publica no broker ✅, mas cai antes de marcar publishPublished no banco. No próximo ciclo, ele vê a mesma mensagem como "não publicada" e publica de novo. O consumidor recebe PaymentConfirmed duas vezes.
Isso não é um defeito do Outbox — é uma verdade fundamental dos sistemas distribuídos. As garantias possíveis:
- At-most-once: cada evento entregue zero ou uma vez (pode perder). Simples, mas perde mensagens — inaceitável para pagamentos.
- At-least-once: cada evento entregue uma ou mais vezes (nunca perde, pode duplicar). É o que Outbox + brokers reais entregam na prática.
- Exactly-once: o graal — nunca perde, nunca duplica. É praticamente impossível de garantir de verdade na entrega de rede (é um resultado teórico conhecido); o que se faz é simular exactly-once com at-least-once + consumidores idempotentes.
A conclusão prática que todo sistema event-driven sério adota: entregue at-least-once e torne os consumidores idempotentes. Você aceita que a mesma mensagem pode chegar N vezes e garante que processá-la N vezes tem o mesmo efeito que processá-la uma vez. Idempotência não é opcional — é o preço de nunca perder um evento.
Consumidores idempotentes na prática
Um consumidor é idempotente quando processar a mesma mensagem repetidamente produz o mesmo estado final. Três técnicas, da mais simples à mais robusta:
Técnica 1 — Tabela de deduplicação (idempotency key). O consumidor registra os IDs de mensagem já processados e ignora repetições.
// ✅ content reage a PaymentConfirmed de forma idempotente
@OnEvent('PaymentConfirmed')
async handle(msg: { messageId: string; userId: string }) {
// se já processei esta messageId, ignoro (dedup)
const alreadyProcessed = await this.processedMessages.exists(msg.messageId);
if (alreadyProcessed) return;
await this.dataSource.transaction(async (m) => {
await this.entitlements.grant(msg.userId, m); // efeito de negócio
await this.processedMessages.record(msg.messageId, m); // marca processado — MESMA transação
});
}
Registrar o messageId na mesma transação do efeito é o detalhe crucial: garante que "concedi acesso" e "marquei como processado" são atômicos — não há como conceder e esquecer de marcar (o que reintroduziria duplicação).
Técnica 2 — Operações naturalmente idempotentes. Desenhe o efeito para ser repetível sem dedup. grant(userId) que faz INSERT ... ON CONFLICT DO NOTHING (ou "marca ativo" em vez de "adiciona +1") é idempotente por natureza: rodar duas vezes deixa o mesmo estado. Prefira essa forma quando possível — é mais simples que manter tabela de dedup.
// ✅ idempotente por design: upsert, não append
async grant(userId: string) {
await this.repo.query(
`INSERT INTO content.entitlements (user_id, active) VALUES ($1, true)
ON CONFLICT (user_id) DO UPDATE SET active = true`, [userId],
); // rodar N vezes = mesmo estado
}
Técnica 3 — Verificação de estado/versão. O consumidor checa se o efeito já foi aplicado antes de aplicá-lo ("a assinatura já está ativa? então não faço nada"). Combina bem com o versionamento de eventos para ignorar eventos fora de ordem (um SubscriptionCancelled antigo não deve sobrescrever um SubscriptionActivated mais novo).
O que não é idempotente e vira bug: efeitos aditivos cegos (balance += amount processado duas vezes cobra dobrado — exatamente a cobrança dupla da aula Transações e consistência, agora na camada de eventos) e chamadas a serviços externos sem chave de idempotência (cobrar o gateway duas vezes). Para efeitos externos, propague uma idempotency key para o serviço (aula Transações e consistência, exercício 5), que o próprio gateway usa para deduplicar.
Sagas: coordenar processos que cruzam módulos
Com State Isolation (aula Persistência e State Isolation), uma transação ACID não atravessa módulos. Mas processos de negócio reais atravessam: um "checkout" no FakeFlix envolve billing (cobrar), identity (validar), content (liberar). Se a cobrança sucede mas a liberação falha, você precisa desfazer a cobrança. Sem transação global, isso vira uma saga: uma sequência de passos locais, cada um com uma ação compensatória que desfaz seu efeito se um passo posterior falhar.
Há duas formas de coordenar uma saga:
Coreografia (via eventos, descentralizada): cada módulo reage ao evento do anterior e emite o seu. Não há maestro; a lógica emerge das reações.
billing: cobra → emite PaymentConfirmed
content: ouve PaymentConfirmed → libera acesso → emite AccessGranted
(se liberar falhar → emite AccessGrantFailed)
billing: ouve AccessGrantFailed → COMPENSA: estorna o pagamento
Prós: desacoplado, sem ponto central, cada módulo autônomo. Contras: o fluxo fica espalhado por vários módulos — ninguém "vê" a saga inteira num lugar, e depurar/entender o processo global exige juntar as peças (o custo de coesão da aula Coesão: o outro lado da moeda aplicado a fluxos). Bom para sagas curtas e estáveis.
Orquestração (um coordenador explícito): um orquestrador dirige os passos, chamando cada módulo e decidindo compensações.
// ✅ orquestrador de saga: o fluxo inteiro visível num lugar
async checkout(userId: string, planId: string) {
const charge = await this.billing.charge(userId, planId); // passo 1
try {
await this.content.grantAccess(userId, planId); // passo 2
} catch (e) {
await this.billing.refund(charge.id); // COMPENSA o passo 1
throw new CheckoutFailedError(userId, e);
}
}
Prós: o processo inteiro é legível e testável num lugar; compensações explícitas; mais fácil de depurar. Contras: o orquestrador conhece os passos (um pouco mais de acoplamento) e é um componente a manter. Bom para sagas longas, complexas ou que mudam bastante.
A escolha (aula Por que não existe arquitetura perfeita): coreografia para fluxos simples e desacoplados; orquestração quando o processo é complexo o bastante para que "ver tudo num lugar" valha o acoplamento do maestro. E o princípio inegociável das sagas: consistência eventual, não atomicidade. Há momentos em que o pagamento foi feito mas o acesso ainda não foi liberado (ou está sendo compensado). O sistema converge; ele não é atômico. Se você precisa de atomicidade real, aquilo não deveria estar em módulos separados (aula Transações e consistência) — os limites da transação desenham os limites dos módulos.
Compensações têm uma sutileza: nem tudo é perfeitamente reversível. Estornar um pagamento é possível; "des-enviar" um e-mail de boas-vindas, não. Por isso ordene a saga para fazer os passos irreversíveis por último (quando possível) e trate o irreversível com compensação semântica (enviar um e-mail corretivo, em vez de fingir que o primeiro não saiu).
Observabilidade: o custo escondido de eventos
Um fluxo síncrono tem um stack trace; um fluxo de eventos tem... pedaços espalhados por consumidores, filas e um relay. Sem observabilidade, um evento perdido ou um consumidor travado é invisível. Três instrumentos obrigatórios:
- Correlação (correlation/causation id): propague um
correlationIddo request original por todos os eventos e consumidores da cadeia (estendendo orequestIdda aula Preparação para produção: testes com factories, logging e JWT entre módulos). Assim você reconstrói "o que aconteceu com o checkout do usuário X" filtrando por um id, através de todos os módulos. - Dead-letter queue (DLQ): mensagens que falham após N tentativas vão para uma fila separada, em vez de sumir ou travar a fila principal. A DLQ é onde você vê "estas 12 mensagens não conseguem ser processadas" e investiga — sem ela, o veneno-na-fila (poison message) para tudo.
- Métricas de fluxo: tamanho da fila (crescendo = consumidor não dá conta), idade da mensagem mais antiga na
outbox(crescendo = relay travado), taxa de retry/DLQ. Esses números avisam antes de o cliente reclamar.
Isso é o outro lado da moeda do event-driven: você ganha desacoplamento e escala (aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos), mas paga com uma superfície de observabilidade muito maior. Não adote eventos sem adotar a observabilidade que os torna operáveis — senão você reencontra o bug da abertura sem ferramentas para vê-lo.
Antes → Depois
| Antes | Depois |
|---|---|
save() depois publish() (dual-write, perde eventos) |
Evento na outbox na mesma transação; relay publica |
| Assume que o evento chega uma vez | At-least-once assumido; consumidor idempotente |
balance += amount no consumer (duplica) |
Upsert / dedup por messageId (idempotente) |
| Processo multi-módulo sem desfazer falhas | Saga com compensações (estorno) |
| Evento perdido é invisível | correlationId, DLQ e métricas de fila |
Tradeoffs e armadilhas
- Outbox adiciona latência e um componente. O relay/CDC introduz um pequeno atraso entre o commit e a publicação, e é mais infraestrutura para manter e monitorar (a
outboxcresce; precisa de limpeza). Você paga isso pela garantia de não perder eventos — vale quando o evento importa (pagamento), pode não valer para um "vídeo assistido" descartável. - Idempotência tem custo de armazenamento e lógica. Tabelas de dedup crescem (precisam de expurgo por janela de tempo) e cada consumidor ganha lógica extra. Prefira operações naturalmente idempotentes (upsert) quando puder, para evitar a tabela de dedup.
- Sagas são difíceis de acertar. Compensações, ordem, passos irreversíveis, estados intermediários visíveis ao usuário — sagas concentram a complexidade essencial (aula Complexidade essencial vs. acidental e carga cognitiva) da consistência distribuída. Não crie uma saga onde uma transação local resolveria (aula Transações e consistência): só quando o processo genuinamente cruza módulos que precisam ser separados.
- Consistência eventual vaza para a UX. "Você pagou, mas o acesso libera em instantes" precisa ser desenhado na interface (estados de "processando", "confirmando"). Ignorar isso gera tickets de suporte ("paguei e não liberou!") mesmo quando o sistema está funcionando corretamente.
- Event-driven não é default. Todo este maquinário (Outbox, idempotência, sagas, DLQ, observabilidade) é caro. Ele se justifica pela dor real de acoplamento temporal/escala (aulas As dimensões do acoplamento: força, distância e tempo, Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos e Quando (e quando NÃO) migrar para microserviços), não por "eventos são modernos". Num monolito modular com chamadas via Public API síncrona (aula Comunicação entre módulos: o padrão Public API Provider), você não precisa de nada disto — e isso é uma virtude, não um atraso.
Cola mental
- Dual-write problem: salvar no banco e publicar no broker não é atômico → eventos se perdem. Transactional Outbox resolve: grava o evento numa tabela na mesma transação do negócio; um relay/CDC publica depois.
- Entrega real é at-least-once (nunca perde, pode duplicar). Exactly-once verdadeiro é impraticável → simule com at-least-once + consumidores idempotentes. Idempotência é obrigatória.
- Idempotência: dedup por
messageId(registrado na mesma transação do efeito), operações naturalmente idempotentes (upsert, não+=), ou verificação de estado/versão. Efeitos externos → idempotency key. - Saga = processo multi-módulo como passos locais + compensações (não há transação global; consistência eventual). Coreografia (eventos, descentralizado, fluxo espalhado) vs. orquestração (maestro, fluxo visível num lugar).
- Observabilidade obrigatória: correlationId, DLQ, métricas de fila/outbox.
- Todo esse maquinário se justifica pela dor real (aulas As dimensões do acoplamento: força, distância e tempo, Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos e Quando (e quando NÃO) migrar para microserviços), não por moda. Public API síncrona não precisa dele.
Referências para Aprofundamento
Os padrões
- Transactional Outbox — Chris Richardson — O padrão que resolve o dual-write: gravar estado e evento na mesma transação, publicar depois.
- Saga — Chris Richardson — Coordenação de processos longos com compensação, nas duas formas (coreografia e orquestração).
- Idempotent Receiver — EIP — Por que a entrega ao menos uma vez transfere o problema para o consumidor, e como recebê-lo bem.
- Change Data Capture com Debezium — A alternativa ao poller do Outbox: ler o log de transações do banco. Útil saber que existe antes de escrever o seu.
Fundo teórico e operação
- Designing Data-Intensive Applications — Martin Kleppmann — O capítulo 11 (processamento de streams) cobre entrega, ordenação e exatamente-uma-vez com o rigor que o assunto exige.
- Life Beyond Distributed Transactions — Pat Helland — Por que, acima de certo tamanho, o dois-fases deixa de ser opção e sobra desenhar para compensação.
- OpenTelemetry — Traces — Sem correlação de ponta a ponta, um fluxo por eventos é indepurável. É o custo escondido que a aula cobra.
Exercícios
Exercício 1 — Explique o bug
Descreva, com os quatro cenários de falha, por que await repo.save(x); await broker.publish(evt) pode deixar o sistema inconsistente. Depois explique, passo a passo, como o Transactional Outbox torna a perda de evento impossível.
Ver resposta
✓ Resposta: Quatro cenários: (i) save ✅ + publish ✅ = ok; (ii) save ✅ e o processo cai antes do publish = fato gravado, ninguém avisado; (iii) save ✅ e broker indisponível no publish = idem; (iv) publish ✅ mas save falha/rollback = outros reagem a um fato que não existe. A raiz é que banco e broker são dois sistemas sem transação comum. Outbox torna a perda impossível: o evento é gravado numa tabela outbox na mesma transação da escrita de negócio — logo, ou os dois commitam juntos, ou nenhum (cenário iv eliminado: sem commit, sem evento; cenários ii/iii eliminados: se o negócio commitou, o evento está na outbox). Um relay separado lê a outbox e publica; se ele ou o broker falharem, o evento permanece na outbox e é republicado no próximo ciclo. O evento não pode "existir no negócio mas não na outbox", porque nasceram na mesma transação.
Exercício 2 — Duplicação do Outbox
O Outbox garante que o evento não se perde, mas pode publicá-lo duas vezes. Descreva o cenário exato (envolvendo o relay caindo) em que isso acontece, e explique por que isso não é um defeito a "consertar", mas uma propriedade a "acomodar".
Ver resposta
✓ Resposta: Cenário: o relay lê uma mensagem não publicada, publica no broker com sucesso, mas cai (ou perde conexão com o banco) antes de executar markPublished. Ao reiniciar, ele relê a outbox, vê a mesma mensagem ainda com publishedAt = null e a publica de novo → o consumidor recebe o evento duas vezes. Por que não é um defeito a consertar: para eliminar a duplicação, o relay precisaria de uma operação atômica "publicar no broker + marcar no banco" — que é exatamente o dual-write problem de novo, só que invertido (broker + banco). Não há como torná-la atômica; qualquer solução recai no mesmo dilema. A resposta correta da indústria é acomodar: aceitar at-least-once (a mensagem pode duplicar) e tornar o consumidor idempotente, de modo que a duplicação seja inofensiva. Tentar "consertar" a duplicação persegue um exactly-once que é teoricamente impraticável; acomodá-la com idempotência é a engenharia madura.
Exercício 3 — Torne idempotente
O consumidor abaixo processa MoneyDeposited e credita a carteira. Ele tem um bug sob entrega at-least-once. Aponte o bug e reescreva-o idempotente de duas formas (uma com dedup por messageId, uma com operação naturalmente idempotente):
@OnEvent('MoneyDeposited')
async handle(msg: { messageId: string; walletId: string; amount: number }) {
const wallet = await this.wallets.findById(msg.walletId);
wallet.balance += msg.amount;
await this.wallets.save(wallet);
}
Ver resposta
✓ Resposta: Bug: wallet.balance += msg.amount é um efeito aditivo cego; sob at-least-once, se MoneyDeposited chegar duas vezes, o saldo é creditado duas vezes — o cliente recebe o dobro. Forma A — dedup por messageId (na mesma transação):
@OnEvent('MoneyDeposited')
async handle(msg) {
await this.dataSource.transaction(async (m) => {
if (await this.processed.exists(msg.messageId, m)) return; // já processei → ignoro
const wallet = await this.wallets.findById(msg.walletId, m);
wallet.balance += msg.amount;
await this.wallets.save(wallet, m);
await this.processed.record(msg.messageId, m); // marca — MESMA transação
});
}
Forma B — operação naturalmente idempotente (ledger append-only com id único): em vez de mutar um saldo, registre a transação de depósito com o messageId como chave única e derive o saldo da soma dos lançamentos:
@OnEvent('MoneyDeposited')
async handle(msg) {
await this.ledger.query(
`INSERT INTO wallet_entries (id, wallet_id, amount) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`, [msg.messageId, msg.walletId, msg.amount],
); // segundo evento com mesmo messageId → ON CONFLICT DO NOTHING → sem duplicar
}
A Forma B é frequentemente superior para dinheiro: além de idempotente por design, dá auditoria completa (cada crédito é um lançamento rastreável).
Exercício 4 — Coreografia ou orquestração?
Para cada processo, recomende coreografia ou orquestração e justifique: (a) um checkout com 5 passos, várias compensações, regras que mudam a cada sprint; (b) "quando um vídeo é publicado, o catálogo, as notificações e o analytics reagem" — três reações independentes e estáveis; (c) um fluxo de assinatura que envolve validar, cobrar, provisionar e, se qualquer passo falhar, desfazer os anteriores em ordem.
Ver resposta
✓ Resposta: (a) Orquestração — 5 passos, várias compensações e regras que mudam toda sprint pedem que o fluxo inteiro seja visível, testável e alterável num lugar; espalhá-lo por coreografia tornaria cada mudança uma caçada por múltiplos módulos. (b) Coreografia — três reações independentes e estáveis a um fato ("vídeo publicado") é o caso ideal de publish/subscribe: billing... digo, content publica o evento e cada interessado reage sozinho, sem maestro; adicionar um quarto interessado não toca ninguém (aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos). (c) Orquestração — "se qualquer passo falhar, desfazer os anteriores em ordem" é a definição de uma saga com compensações sequenciais, que fica muito mais clara e confiável com um orquestrador dirigindo a ordem e as compensações do que emergindo de reações espalhadas.
Exercício 5 — Projete a saga
Desenhe (em passos e compensações) uma saga de checkout do FakeFlix: cobrar o cartão (billing), liberar o acesso (content), enviar e-mail de boas-vindas (notifications). Identifique qual passo é irreversível, como você ordena a saga por causa disso, e o que acontece se a liberação de acesso falhar após a cobrança.
Ver resposta
✓ Resposta: Saga de checkout (passos → compensações):
- Passo 1 — cobrar o cartão (billing). Compensação: estornar (
refund). Reversível. - Passo 2 — liberar o acesso (content). Compensação: revogar o acesso. Reversível.
- Passo 3 — enviar e-mail de boas-vindas (notifications). Compensação: não há "des-enviar"; é irreversível.
Ordenação por causa do irreversível: coloque o passo irreversível (e-mail) por último, depois que os passos reversíveis (cobrança, liberação) já sucederam. Assim, se algo falhar, você ainda pode compensar tudo o que veio antes, e o e-mail (irreversível) só dispara quando o sucesso já é certo — evitando o pior caso de "enviei boas-vindas e depois estornei tudo". Se a liberação de acesso (passo 2) falhar após a cobrança (passo 1): o orquestrador executa a compensação do passo 1 — estorna o pagamento — e encerra o checkout com falha, deixando o sistema consistente (cliente não cobrado, sem acesso). O e-mail nunca é enviado, porque estava ordenado para depois. Note a consistência eventual: houve um intervalo em que o cliente estava cobrado sem acesso, até o estorno completar — a UX deve refletir isso ("finalizando sua assinatura...") em vez de afirmar sucesso prematuramente. Se o e-mail fosse obrigatoriamente antes da liberação, a compensação seria semântica (um e-mail corretivo), o que reforça a regra de deixar o irreversível por último.