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.
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 atualizaprocessed_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.