O padrão transactional outbox para publicação confiável de eventos
Publicar um evento após commit no banco cria uma janela de falha onde a escrita tem sucesso e a mensagem nunca sai. O padrão transactional outbox escreve estado de negócio e eventos de saída em uma transação — depois retransmite de forma assíncrona com entrega at-least-once e consumidores idempotentes.
O pedido foi marcado como pago. O banco fez commit. O broker de mensagens nunca recebeu OrderPaid — a aplicação crashou entre COMMIT e publish(). O estoque ainda mostrava disponibilidade. O armazém vendeu a mais. O engenheiro de plantão chamou de blip de rede transitório e reproduziu manualmente a partir de logs. O padrão transactional outbox existe porque dual writes não são atômicos: mudança de estado e publicação de evento são dois sistemas sem fronteira de transação compartilhada a menos que o evento seja escrito dentro do mesmo commit que a linha de negócio.
Arquiteturas event-driven prometem acoplamento frouxo — serviços reagem a fatos em vez de se chamar de forma síncrona. A promessa quebra na costura onde fatos saem do banco. Sem outbox, equipes escolhem entre publicar antes do commit (eventos por trabalho não confirmado), publicar após commit (eventos perdidos em crash) ou transações distribuídas (impraticáveis entre Postgres e Kafka). O outbox é o terceiro caminho pragmático: persistir a intenção de publicar atomicamente com a mudança de estado, depois retransmitir em processo separado que pode retentar sem mentir sobre durabilidade.
The dual-write problem and why it persists
Dual write: atualizar tabela de domínio, depois chamar API do broker. Duas operações independentes. Modos de falha:
| Failure point | Database state | Broker state | User-visible result |
|---|---|---|---|
| Crash após commit, antes de publish | Atualizado | Sem mensagem | Downstream defasado; silencioso |
| Publish tem sucesso, DB faz rollback | Inalterado | Mensagem enviada | Evento fantasma; sobreprocessamento |
| Timeout de publish, resultado desconhecido | Atualizado | Desconhecido | Duplicata ou ausência em retry |
| Batch parcial em loop | Algumas linhas atualizadas | Algumas mensagens enviadas | Visão cross-service inconsistente |
"Usar transação" não abrange Postgres e RabbitMQ. Commit em duas fases entre stores heterogêneos adiciona custo operacional que a maioria das equipes de produto rejeita. Coreografia saga sem eventos de saída duráveis empurra complexidade para consumidores compensando eventos que nunca chegaram.
Se o evento não está na mesma transação que a mudança de estado, o evento não está garantido.
O outbox colapsa dual write em escrita única: uma transação, dois inserts (ou update + insert), um commit. Publicação vira problema de ler-e-encaminhar — resolvível com retries, idempotência e monitoramento.
Outbox table design and the atomic write
Esquema mínimo de outbox:
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
publish_attempts INT NOT NULL DEFAULT 0
);
CREATE INDEX outbox_unpublished_idx
ON outbox (created_at)
WHERE published_at IS NULL;Transação de aplicação:
BEGIN;
UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = $1 AND status = 'pending';
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES (
'order',
$1::text,
'OrderPaid',
jsonb_build_object(
'order_id', $1,
'amount_cents', $2,
'paid_at', now()
)
);
COMMIT;Se a transação faz commit, o evento existe. Se faz rollback, nem estado nem evento persistem. O timing de crash não cria mais commit-sem-evento — a linha do evento faz parte do commit.
Convenções de payload: incluir versão de esquema, correlation ID e causation ID para rastreabilidade. Manter payloads pequenos — referenciar blobs grandes por ID em vez de embutir. Strings de event type devem ser estáveis entre deploys; renomeações exigem estratégia de compatibilidade com consumidores.
Relay patterns: polling, CDC, and dedicated publishers
Algo deve mover linhas de outbox para o broker. Três abordagens comuns:
Polling publisher. Um worker consulta linhas não publicadas, publica, marca published_at. Simples, funciona em qualquer lugar, introduz latência de intervalo de poll.
SELECT id, event_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;FOR UPDATE SKIP LOCKED permite múltiplas instâncias de publisher dividirem trabalho sem dupla publicação da mesma linha em uma passagem — embora at-least-once ao broker ainda exija idempotência do consumidor.
Change data capture (CDC). Debezium ou replicação lógica transmite eventos inseridos da tabela outbox. Menor latência que polling; adiciona infraestrutura CDC e superfície operacional. Encaixa em equipes que já rodam Kafka Connect.
In-process relay after commit. Mesma thread da aplicação publica pós-commit — recria a janela de dual write salvo implementação cuidadosa com callbacks transacionais. Geralmente desaconselhado como padrão principal; hooks pós-commit ainda falham em crash.
Polling é subestimado para throughput moderado. CDC vence em alto volume e requisitos de latência subsegundo. Ambos marcam published_at só após ack do broker — se marcação falhar após publicação bem-sucedida, ocorre entrega duplicada; consumidores devem deduplicar.
Tratamento de falhas do relay: incrementar publish_attempts, backoff exponencial, dead-letter após limiar, alertar sobre linhas não publicadas mais antigas que o SLO. Linhas não publicadas obsoletas são o equivalente outbox de profundidade de fila — colocá-las em dashboard.
Delivery semantics: at-least-once end to end
O outbox garante que o evento eventualmente sairá do banco se o relay rodar. Não garante entrega exactly-once a consumidores. Retries do broker, crashes do relay após publish-antes-de-marcar e semântica at-least-once do broker produzem duplicatas.
Stack de confiabilidade completo:
Outbox (atomic with state) → relay (at-least-once) → broker (at-least-once)
→ consumer (idempotent handler)
Idempotência do consumidor usa dedup por event ID — os mesmos padrões de upserts idempotentes no Postgres: inserir event ID em tabela processed na mesma transação que efeitos colaterais, ou upsert de estado de domínio com guardas de versão monotônica.
O event_id do outbox deve propagar como message key ou header. Consumidores deduplicam por event_id, não por message ID do broker — IDs do broker diferem em redelivery.
Garantias de ordenação são por agregado, não globais. Particionar por aggregate_id para que OrderPaid do pedido 42 sempre caia na mesma partição se ordem importar. Ordenação cross-aggregate não é fornecida — projetar consumidores em consequência.
Outbox vs full event sourcing: scope the pattern correctly
O outbox resolve publicação confiável a partir de escritas OLTP. Não é event sourcing — embora ambos componham.
event sourcing without ceremony descreve logs de domínio append-only como fonte de auditoria e projeção. O outbox é tabela de integração: eventos saem do limite do serviço. Um serviço pode fazer event sourcing internamente e ainda usar outbox para notificações cross-service — ou usar só outbox sem replay de estado interno a partir de eventos.
| Concern | Outbox | Event store |
|---|---|---|
| Primary goal | Publicação cross-service confiável | Histórico de domínio completo + replay |
| Read pattern | Relay escaneia linhas não publicadas | Consumo de stream, projeções |
| Retention | Deletar após publish + buffer | Append-only, retenção longa |
| Schema evolution | Payloads forward-compatible | Upcasting de eventos versionados |
Às vezes equipes armazenam todos os eventos de domínio em tabela de eventos e tratam linhas não publicadas como outbox — viável em escala pequena. Em escala maior, separar outbox de integração do log de eventos de domínio clarifica retenção, indexação e padrões de acesso. Linhas outbox são deletadas ou arquivadas após publish; eventos de domínio permanecem.
Operational concerns: cleanup, backpressure, and migrations
Retention and cleanup. Linhas publicadas se acumulam. Arquivar em armazenamento frio ou deletar após sete a trinta dias conforme necessidades de replay e auditoria. Particionar outbox por semana para drops baratos.
Backpressure. Se lag do relay cresce — broker fora, consumidor lento — linhas não publicadas se acumulam. Alertar sobre contagem e idade de linhas. Considerar pausar produtores não críticos ou shedding de carga antes de bloat da tabela outbox afetar performance OLTP.
Schema migrations. Migrações da tabela outbox seguem disciplina de migração zero-downtime — o outbox está no hot path. Adicionar colunas nullable; backfill; contrair depois. Nunca bloquear relay com locks exclusivos em pico.
Multi-region. Outbox assume escritas single-primary. Active-active global com outbox exige resolução de conflitos em aggregate IDs — geralmente evitado. Outbox encaixa com single-primary por agregado e réplicas de leitura regionais.
Testing. Testes de integração afirmam: commit sem relay → linha não publicada; relay → broker recebe exatamente um evento lógico por event_id; crash após publish antes de marcar → redelivery sem efeito duplicado do consumidor quando idempotente.
How should teams implement the transactional outbox?
Escolhas práticas para serviços enviando integrações event-driven.
Polling interval vs CDC: how to choose?
Polling a cada um a cinco segundos com SKIP LOCKED lida com milhares de eventos por minuto em hardware modesto. CDC quando latência subsegundo, throughput muito alto ou infraestrutura Kafka existente justifica custo operacional. Começar com polling; migrar para CDC quando métricas provarem necessidade.
Should the outbox share a database with domain tables?
Sim, para a garantia atômica. Outbox em banco separado recria dual write salvo que ambos participem de XA — o que derrota a simplicidade. Colocar outbox com o banco OLTP primário do serviço.
When can post-commit publish replace outbox?
Quando eventos perdidos são aceitáveis (só analytics), quando batch de reconciliação sana lacunas em horas, ou quando volume é baixo e replay manual é tolerável. Não quando inventário, faturamento ou fulfillment downstream dependem de eventos oportunos.
How does outbox interact with sagas?
Sagas coordenam fluxos de trabalho distribuídos multi-etapa. A transação local de cada etapa inclui eventos outbox para os triggers da próxima etapa. Etapas compensatórias emitem eventos compensatórios pelo mesmo outbox — consumidores idempotentes aplicam eventos forward e compensatórios com segurança.
A common argument runs the other way
A visão oposta sustenta que brokers modernos e mensageria cloud-managed são confiáveis o suficiente para publicar diretamente após commit, especialmente com publisher confirms e bibliotecas de retry.
Publisher confirms provam que o broker recebeu a mensagem — não que a aplicação a enviou. A janela de crash entre commit no banco e HTTP POST ao broker é microssegundos a milissegundos em cada request — rara individualmente, garantida em escala. Bibliotecas de retry ajudam em falhas transitórias; não ajudam com morte de processo entre commit e primeira tentativa de publish. Outbox move intenção de publicação para dentro do limite de commit durável; retries do relay se desacoplam do ciclo de vida do request. Publicação direta é aceitável para analytics best-effort; é insuficiente quando correção downstream depende de cada mudança de estado produzir um evento.
Key takeaways
- Dual write entre banco e broker não é atômico — outbox escreve eventos na mesma transação que estado de negócio.
- Processos relay fazem poll ou CDC de linhas não publicadas; marcar publicado só após ack do broker.
- Confiabilidade end-to-end exige consumidores idempotentes keyed em event ID — at-least-once é a garantia realista.
- Outbox é infraestrutura de integração, não event sourcing — escopar retenção e esquema separadamente de logs de eventos de domínio.
- Monitorar idade e contagem de linhas não publicadas — lag do relay é métrica de primeira classe.
- Particionar trabalho com
FOR UPDATE SKIP LOCKED; particionar mensagens por aggregate ID para ordenação. - Testar timing de crash: commit sem relay, publish sem marcar, entrega duplicada ao consumidor.
Conclusion
Publicação confiável de eventos é problema de banco disfarçado de problema de mensageria. O padrão transactional outbox torna durável a intenção de publicação junto a escritas de negócio, depois delega entrega a processos relay retentáveis que toleram falhas de broker e processo sem lacunas silenciosas.
Serviços emitindo eventos de domínio sem outbox devem auditar a janela de crash entre commit e publish — e medir quantas mudanças de estado nunca produziram mensagem downstream. A correção é uma tabela, um insert extra por transação e um loop de publisher que operações pode monitorar como qualquer outra métrica de profundidade de fila.