Outbox Pattern no Node.js resolve um problema clássico: salvar uma mudança no banco e publicar um evento no broker sem perder consistência. Se a aplicação grava o pedido e cai antes de publicar, outros serviços nunca recebem o fato. Se publica primeiro e a transação falha, consumidores reagem a algo que não foi confirmado.
O padrão grava estado e mensagem Outbox na mesma transação. Um worker separado lê mensagens pendentes, publica no broker e marca o envio.
O problema da dupla escrita
await pedidos.salvar(pedido);
await broker.publish(event);Essas duas operações não formam uma transação atômica. Qualquer falha entre elas cria divergência.
Tabela Outbox
CREATE TABLE outbox (
id uuid PRIMARY KEY,
topic text NOT NULL,
event_type text NOT NULL,
aggregate_id text NOT NULL,
payload jsonb NOT NULL,
metadata jsonb NOT NULL,
created_at timestamptz NOT NULL,
published_at timestamptz,
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz
);Gravação atômica
await db.transaction(async trx => {
await pedidos.salvar(pedido, trx);
for (const event of pedido.removerEventos()) {
await trx.query(
`INSERT INTO outbox
(id, topic, event_type, aggregate_id, payload, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[event.id, 'orders', event.type, pedido.id, event.data, event.metadata, event.occurredAt]
);
}
});Se a transação falhar, nem pedido nem evento são persistidos.
Worker publicador
async function publicarLote() {
const rows = await db.query(`
SELECT * FROM outbox
WHERE published_at IS NULL
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
`);
for (const row of rows.rows) {
await broker.publish(row.topic, {
id: row.id,
type: row.event_type,
aggregateId: row.aggregate_id,
data: row.payload,
metadata: row.metadata
});
await db.query(
'UPDATE outbox SET published_at = now() WHERE id = $1',
[row.id]
);
}
}SKIP LOCKED permite vários workers sem processar a mesma linha simultaneamente.
Ainda pode haver duplicação
O worker pode publicar e cair antes de marcar published_at. Na próxima execução, a mensagem será enviada novamente. Por isso, consumidores precisam ser idempotentes.
Inbox no consumidor
await db.transaction(async trx => {
const inserted = await trx.query(
`INSERT INTO inbox (event_id, processed_at)
VALUES ($1, now())
ON CONFLICT DO NOTHING
RETURNING event_id`,
[event.id]
);
if (inserted.rowCount === 0) return;
await projeção.aplicar(event, trx);
});Polling ou CDC
O worker pode fazer polling periódico na tabela. Outra opção é Change Data Capture, em que uma ferramenta lê o log do banco e publica mudanças. Polling é mais simples; CDC pode reduzir latência e carga, mas adiciona infraestrutura.
Backoff
Falhas de publicação devem atualizar tentativas e próximo horário.
const delaySeconds = Math.min(300, 2 ** attempts);
await db.query(
`UPDATE outbox
SET attempts = attempts + 1,
next_attempt_at = now() + ($2 || ' seconds')::interval
WHERE id = $1`,
[id, delaySeconds]
);Mensagens bloqueadas
Após um limite, mova para estado de falha ou tabela separada. Alertas devem mostrar quantidade, idade e motivo.
Limpeza
Mensagens publicadas não devem crescer indefinidamente. Defina retenção, arquivamento e job de limpeza em lotes pequenos.
Ordenação
Se ordem por aggregate for necessária, inclua versão e use a mesma chave de partição. Vários workers podem publicar aggregates diferentes em paralelo.
Contrato do evento
A Outbox não deve armazenar apenas objetos internos serializados. Crie um evento de integração versionado, com payload mínimo e nomes estáveis.
Transação e conexão
Repository e gravação da Outbox precisam usar a mesma conexão e transação. Um “transaction helper” que abre conexões diferentes não oferece atomicidade real.
Observabilidade
Monitore:
- linhas pendentes;
- idade da mais antiga;
- taxa de publicação;
- latência entre criação e envio;
- tentativas e falhas;
- tamanho da tabela;
- duplicatas detectadas no Inbox.
Testes
- rollback não deixa evento;
- commit grava estado e Outbox;
- worker publica e marca;
- falha agenda retry;
- queda após publicação gera duplicata segura;
- dois workers não processam a mesma linha;
- limpeza preserva mensagens pendentes.
Erros comuns
- gravar Outbox fora da transação;
- assumir exactly-once;
- consumidor não idempotente;
- sem índice para pendentes;
- polling agressivo;
- payload sem versão;
- sem retenção;
- não alertar mensagens antigas.
Conclusão
Outbox Pattern no Node.js transforma uma dupla escrita insegura em uma gravação local atômica e uma publicação repetível. Ele não elimina duplicatas; torna a entrega recuperável.
Combine Outbox com Inbox, idempotência, retries e observabilidade. Leia também Domain Events no Node.js e Event-Driven Architecture no Node.js.
Consulte o padrão Transactional Outbox e a documentação do PostgreSQL sobre SELECT e locking.



