Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

Outbox Pattern no Node.js: eventos sem perder dados

Atualizado em: 15 de outubro de 2026

Terminal Linux usado em desenvolvimento e administração de aplicações Node.js

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.

10 melhores cursos de programação em 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita