System Design

Outbox Pattern

Garantir que uma operação de banco de dados e a publicação de um evento sejam atômicas — sem precisar de uma transação distribuída entre banco e broker de mensagens.

Intenção

O Outbox Pattern garante que dados persistidos no banco de dados e eventos publicados no broker de mensagens nunca se dessincronizem, usando a própria transação de banco como mecanismo de atomicidade. O evento não vai diretamente ao broker — vai primeiro a uma tabela de banco dentro da mesma transação que modifica os dados de negócio.

A palavra "outbox" vem da analogia com a caixa de saída de e-mails: mensagens que você redige ficam na caixa de saída antes de serem enviadas. Se o servidor cair enquanto você redige, a mensagem está salva e será enviada quando o servidor voltar. O mesmo princípio se aplica aqui — o evento fica "na caixa de saída" (tabela de banco) até que um processo separado o entregue ao broker.

Problema

Em sistemas event-driven, a operação de negócio tipicamente precisa fazer duas coisas: persistir o novo estado no banco de dados e publicar um evento para notificar outros serviços. O problema é que banco de dados e broker de mensagens são recursos independentes — não há como incluir ambos numa única transação ACID.

  // Código ingênuo — duas operações independentes sem atomicidade
  await db.save(pedido);              // ← banco commita aqui
  await broker.publish('PedidoCriado', pedido);  // ← e se isso falhar?

  // Cenário 1: banco commita, broker falha
  //   → pedido existe, evento perdido, outros serviços nunca sabem
  //   → inconsistência silenciosa

  // Cenário 2: broker recebe, banco falha
  //   → evento publicado, pedido não existe
  //   → consumidores processam um pedido fantasma

Qualquer falha entre as duas operações — crash do processo, timeout de rede, erro no broker — deixa o sistema em estado inconsistente. E o problema é silencioso: não há erro explícito visível; simplesmente os sistemas ficam dessincronizados.

Como funciona

A tabela Outbox

A solução é mover a publicação do evento para dentro da transação de banco. Em vez de publicar diretamente no broker, a aplicação insere o evento como uma linha numa tabela chamada outbox (ou equivalente), dentro da mesma transação que salva os dados de negócio.

  -- Schema da tabela outbox
  CREATE TABLE outbox (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type  TEXT NOT NULL,             -- ex.: 'PedidoCriado'
    payload     JSONB NOT NULL,            -- dados do evento serializados
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    processed_at TIMESTAMPTZ              -- NULL = aguardando relay
  );

  -- Dentro da mesma transação (ACID local):
  BEGIN;
    INSERT INTO pedidos (id, cliente_id, total) VALUES (...);
    INSERT INTO outbox (event_type, payload)
      VALUES ('PedidoCriado', '{"pedido_id": "...", "total": 99.90}');
  COMMIT;
  -- Se o COMMIT falhar, nenhuma das duas inserções acontece.
  -- Se o COMMIT tiver sucesso, ambas as inserções estão garantidas.
Application (serviço) Transação Local (ACID) pedidos id, cliente_id total, status outbox event_type payload, ... COMMIT ambas ou nenhuma são persistidas Message Relay polling ou CDC Broker Kafka / RabbitMQ publica Consumers outros serviços marca processed_at após confirmar envio
Outbox Pattern: dentro de uma única transação local ACID, a aplicação escreve nos dados de negócio e na tabela outbox. O Message Relay lê a outbox e publica no broker de forma assíncrona.

Message Relay: Polling vs CDC

O Message Relay é o processo responsável por ler a outbox e publicar no broker. Existem duas abordagens:

  • Polling: um processo roda periodicamente (ex.: a cada 500ms), busca linhas da outbox onde processed_at IS NULL, publica cada evento no broker e atualiza processed_at. Simples de implementar, mas introduz latência proporcional ao intervalo de polling e gera carga constante no banco.
  • CDC (Change Data Capture): captura as inserções na tabela outbox diretamente do log de transações do banco (ex.: Debezium lendo o WAL do PostgreSQL). Menor latência, sem polling constante e sem carga adicional nas queries. Mais complexo de configurar e operar.

Semântica at-least-once

O Outbox Pattern garante que eventos nunca se percam (nenhum evento é descartado silenciosamente), mas não garante entrega exatamente uma vez. Se o relay publicar o evento e falhar antes de marcar como processado, vai republicar na próxima execução. Consumers devem ser idempotentes — processar o mesmo evento duas vezes não deve ter efeito diferente de processá-lo uma vez.

  -- Relay pode publicar duplicatas. Consumers precisam de idempotência:
  INSERT INTO pedido_estoque (pedido_id, quantidade)
  VALUES ($1, $2)
  ON CONFLICT (pedido_id) DO NOTHING;  -- idempotente: ignora duplicata

Quando usar

  • Sempre que precisar de atomicidade entre banco e broker: se o negócio não tolera perda de eventos (pedido criado sem notificar o serviço de estoque, por exemplo), Outbox é o padrão correto.
  • Sistemas event-driven com consistência eventual: quando os consumidores dos eventos podem processar de forma assíncrona e a latência de entrega do relay (milissegundos a segundos) é aceitável.
  • Como base de um Saga Pattern: cada passo da saga pode usar a outbox para garantir que o evento disparando o próximo passo nunca seja perdido — mesmo que o processo caia imediatamente após o commit.

Quando evitar

  • Latência sub-milissegundo exigida: o relay introduz latência adicional (polling) ou complexidade operacional (CDC). Para notificações em tempo real sem tolerância a esse delay, explore outras abordagens.
  • Sem job de limpeza da outbox: sem um processo periódico que remova eventos processados, a tabela cresce indefinidamente. Esse custo de operação precisa ser aceito antes de adotar o padrão.

Prós e contras

Prós

  • Atomicidade entre dados de negócio e evento usando apenas transação local de banco — sem transação distribuída.
  • Eventos nunca são perdidos: mesmo que o broker esteja indisponível no momento da escrita, o evento fica na outbox e será publicado quando o broker voltar.
  • Simples de implementar com polling; a adoção de CDC pode vir depois como otimização.
  • Compatível com qualquer banco relacional que suporte transações ACID.

Contras

  • Semântica at-least-once: consumers precisam ser idempotentes, o que adiciona complexidade.
  • Latência adicional: o relay introduz um delay entre o commit e a entrega do evento ao broker.
  • Operação da tabela outbox: limpeza periódica, monitoramento de lag do relay e configuração de CDC são responsabilidades adicionais.
  • CDC requer atenção à configuração do WAL do banco (retenção mínima compatível com a velocidade do relay).

Armadilhas comuns

1. Esquecer de limpar a outbox

Sem um job de limpeza (DELETE FROM outbox WHERE processed_at < NOW() - INTERVAL '7 days'), a tabela cresce indefinidamente. Queries de polling ficam progressivamente mais lentas e o storage aumenta sem controle. Defina a política de retenção antes de colocar em produção e monitore o tamanho da tabela.

2. Relay sem idempotência e consumers não preparados

O relay pode publicar o mesmo evento mais de uma vez em caso de falha. Se os consumers não forem idempotentes, operações como "reservar estoque" ou "enviar e-mail de confirmação" serão executadas em duplicata. Documente essa semântica explicitamente e garanta que todo consumer da outbox implemente idempotência.

3. CDC perdendo eventos por WAL rotation

O Debezium (e outros conectores CDC) lê o Write-Ahead Log do PostgreSQL. Se o WAL for rotacionado antes do CDC processar as inserções da outbox — o que pode acontecer em períodos de alto volume de escrita ou se o CDC ficar offline por tempo longo — os eventos são perdidos permanentemente. Configure wal_keep_size adequadamente e monitore o lag do CDC.

4. Outbox com mensagens de alta frequência sem otimização

Polling a cada segundo em uma tabela com milhões de linhas processadas gera carga desnecessária. Indexe a coluna processed_at (ou use uma coluna status indexada), limite o tamanho do batch por execução do relay e considere migrar para CDC quando o volume crescer.

Regra prática: a tabela outbox deve ter sempre menos de 1.000 linhas não processadas em operação normal. Se o número crescer, algo está errado com o relay — investigue antes que o lag cause inconsistências perceptíveis.

Arquiteturas e padrões relacionados

O Outbox Pattern é quase sempre usado em conjunto com Sagas: cada participante da saga salva seu resultado e insere o evento que dispara o próximo passo na outbox, dentro da mesma transação local. Isso garante que o fluxo da saga não se quebra por falha de rede entre o commit e a publicação.

Em Event-Driven Architecture, o Outbox é a solução canônica para o problema de dual-write: a aplicação não pode garantir atomicidade escrevendo no banco e publicando no broker em operações separadas.

CQRS com Event Sourcing frequentemente usa o Outbox ou o próprio event store como mecanismo de publicação: o evento é a fonte de verdade do estado e também o gatilho para a atualização do Read Model — mas ainda é necessário garantir que o evento chegue ao broker de forma confiável.