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 correlationId do request original por todos os eventos e consumidores da cadeia (estendendo o requestId da 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 outbox cresce; 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

Fundo teórico e operação

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.