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

Outbox Pattern no Node.js

Atualizado em: 25 de agosto de 2026

Rack de servidores processando fluxos de dados no Node.js

O Outbox Pattern no Node.js resolve um problema clássico de sistemas distribuídos: atualizar o banco e publicar um evento sem correr o risco de confirmar apenas uma das duas operações. A aplicação grava a mudança de negócio e uma mensagem de outbox na mesma transação PostgreSQL. Depois, um worker publica o evento no broker e marca a linha como processada.

Sem esse padrão, um pedido pode ser criado no banco e o processo cair antes de enviar order.created. O oposto também pode ocorrer: a mensagem é publicada, mas o commit falha. Como PostgreSQL e Kafka, RabbitMQ ou outro broker não compartilham a mesma transação local, a aplicação precisa de uma estratégia explícita.

Neste guia, você aprenderá a modelar a tabela, gravar eventos atomicamente, implementar o relay, usar SKIP LOCKED, controlar retries, garantir idempotência, ordenar mensagens, limpar dados e observar o fluxo.

O que é Transactional Outbox?

Transactional Outbox grava mensagens pendentes no mesmo banco da operação de negócio. A referência Transactional Outbox no catálogo Microservices.io descreve o padrão. A documentação oficial de transações do PostgreSQL apresenta as garantias usadas.

Para a implementação transacional, consulte Transações PostgreSQL no Node.js. Para repetição segura, veja Idempotência em APIs Node.js.

O problema do dual write

await database.insertOrder(order);
await broker.publish('order.created', event);

Existem duas escritas independentes. Se a segunda falha, o banco fica sem evento. Se a ordem é invertida e o commit falha, consumidores recebem um evento de um pedido inexistente.

Tabela de outbox

CREATE TABLE outbox_events (
  id UUID PRIMARY KEY,
  aggregate_type TEXT NOT NULL,
  aggregate_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  headers JSONB NOT NULL DEFAULT '{}'::jsonb,
  occurred_at TIMESTAMPTZ NOT NULL,
  available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ,
  attempts INTEGER NOT NULL DEFAULT 0,
  last_error TEXT
);

CREATE INDEX idx_outbox_pending
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;

O índice parcial acelera a busca apenas por mensagens pendentes.

Gravação atômica

await withTransaction(pool, async client => {
  const order = await createOrder(client, input);

  await client.query(`
    INSERT INTO outbox_events (
      id,
      aggregate_type,
      aggregate_id,
      event_type,
      payload,
      occurred_at
    ) VALUES ($1, $2, $3, $4, $5, now())
  `, [
    crypto.randomUUID(),
    'order',
    String(order.id),
    'order.created',
    JSON.stringify({
      orderId: order.id,
      customerId: order.customerId,
      totalCents: order.totalCents
    })
  ]);
});

Se qualquer comando falha, pedido e evento são revertidos.

Payload mínimo

Inclua apenas dados necessários para consumidores. Evite copiar registros inteiros, tokens ou informações pessoais sem finalidade.

Schema do evento

{
  "eventId": "...",
  "eventType": "order.created",
  "eventVersion": 1,
  "occurredAt": "2026-08-25T18:00:00.000Z",
  "aggregate": {
    "type": "order",
    "id": "842",
    "version": 3
  },
  "data": {
    "customerId": "91",
    "totalCents": 15990
  }
}

Versione o contrato do evento independentemente da versão do código.

Relay por polling

Um worker consulta linhas pendentes em intervalos curtos:

SELECT id, event_type, payload, headers
FROM outbox_events
WHERE published_at IS NULL
  AND available_at <= now()
ORDER BY occurred_at, id
FOR UPDATE SKIP LOCKED
LIMIT $1;

SKIP LOCKED permite vários workers sem selecionar a mesma linha simultaneamente.

Marcando como processando

Uma estratégia é manter a transação aberta enquanto publica, mas isso segura locks durante I/O externo. Uma alternativa é reivindicar linhas rapidamente:

UPDATE outbox_events
SET attempts = attempts + 1,
    available_at = now() + interval '30 seconds'
WHERE id = ANY($1::uuid[])
RETURNING *;

Depois do commit, o worker publica. Se cair, as linhas voltam a ficar disponíveis após o lease.

Publicação e marcação

for (const event of events) {
  try {
    await broker.publish(event.event_type, event.payload, {
      messageId: event.id,
      headers: event.headers
    });

    await pool.query(`
      UPDATE outbox_events
      SET published_at = now(),
          last_error = NULL
      WHERE id = $1
    `, [event.id]);
  } catch (error) {
    await registerFailure(event.id, error);
  }
}

Existe uma janela entre publicar e marcar como concluído. Se o processo cai nessa janela, o evento será publicado novamente.

Entrega pelo menos uma vez

Outbox normalmente fornece entrega at least once. Consumidores devem aceitar duplicatas.

Idempotência do consumidor

INSERT INTO processed_events (
  consumer_name,
  event_id,
  processed_at
) VALUES ($1, $2, now())
ON CONFLICT DO NOTHING;

O registro do evento e o efeito local devem ocorrer na mesma transação do consumidor.

Message ID

Use o ID da outbox como ID da mensagem. Não gere outro valor em cada tentativa, pois o consumidor não reconhecerá a duplicata.

Retries

Falhas transitórias devem reagendar com backoff e jitter:

const delaySeconds = Math.min(
  300,
  2 ** attempts + Math.random() * 5
);

Consulte Retry com Backoff no Node.js.

Dead-letter

Depois de um limite, mova ou marque o evento para análise:

UPDATE outbox_events
SET failed_at = now(),
    last_error = $2
WHERE id = $1;

Não descarte silenciosamente. Crie alerta e procedimento de reprocessamento.

Erros permanentes

Schema inválido, tópico inexistente e credencial revogada não melhoram com retry infinito. Classifique falhas e suspenda o relay quando necessário.

Ordenação

Eventos do mesmo agregado podem precisar de ordem. Inclua aggregate_version e particione no broker pela chave do agregado.

Ordem global

Uma ordem total de todos os eventos reduz escalabilidade e raramente é necessária. Prefira ordem por pedido, cliente ou outra entidade.

Concorrência entre workers

SKIP LOCKED distribui linhas, mas dois eventos do mesmo agregado podem ir para workers diferentes. Reivindique por agregado, publique em partição ordenada ou limite o lote.

Event version

Quando o contrato muda, aumente eventVersion. Consumidores devem suportar a versão atual e, durante migração, versões anteriores.

Compatibilidade

Adicionar campos opcionais é mais seguro que remover ou mudar tipos. Mantenha o evento antigo até todos os consumidores migrarem.

Headers

Inclua correlation ID, causation ID, trace context e content type. Não inclua Authorization.

Correlation ID

Permite acompanhar uma operação entre API, outbox, broker e consumidores. Veja Logs com Pino no Node.js.

OpenTelemetry

Propague contexto de trace nos headers e crie span de publicação. Consulte OpenTelemetry no Node.js.

Polling interval

Intervalos menores reduzem latência e aumentam consultas. Use índice, lote e espera adaptativa.

Notificação com LISTEN/NOTIFY

O commit pode executar NOTIFY para acordar o relay, enquanto o polling permanece como fallback. NOTIFY não deve ser a única fonte, pois notificações não são uma fila durável.

CDC

Change Data Capture pode ler o WAL e publicar linhas da outbox. Essa estratégia reduz polling, mas adiciona infraestrutura e gestão de slots de replicação.

Outbox com Kafka

Use o aggregate ID como key para preservar ordem por partição. Confirme acknowledgements e mantenha o mesmo message ID nos retries.

Outbox com RabbitMQ

Use publisher confirms. Uma confirmação perdida pode gerar repetição; o consumidor continua precisando de idempotência.

Outbox com HTTP

O relay também pode chamar webhooks. Use assinatura, timeout, retry e chave de idempotência. Consulte Webhooks Seguros com Node.js.

Limpeza

Apague eventos publicados em lotes:

DELETE FROM outbox_events
WHERE published_at < now() - interval '30 days'
  AND id IN (
    SELECT id
    FROM outbox_events
    WHERE published_at < now() - interval '30 days'
    LIMIT 10000
  );

Lotes evitam uma transação enorme.

Particionamento

Em alto volume, particione por data e remova partições antigas. Planeje índices e queries antes de crescer.

Bloat

Muitos UPDATEs em published_at geram versões mortas. Monitore autovacuum e tamanho da tabela.

Estado imutável

Outra opção move eventos processados para tabela histórica ou deleta após publicação. Escolha conforme auditoria e reprocessamento.

Reprocessamento

Não altere o ID ao reenviar. Registre quem solicitou, motivo e tentativa. Um replay em massa precisa de limite.

Deploy

Implante consumidores compatíveis antes de publicar um novo event type. Feature flags podem controlar o início da emissão.

Migrations

Crie a tabela e índices com uma migration segura. Consulte Migrações de Banco no Node.js.

Graceful shutdown

O relay deve parar de reivindicar linhas, concluir publicações em andamento e liberar recursos. Eventos não confirmados voltarão após o lease.

Health checks

Readiness pode considerar conexão com banco e broker, mas uma falha do broker não deve reiniciar a aplicação em loop.

Métricas

Monitore:

  • eventos pendentes;
  • idade do mais antigo;
  • publicações por segundo;
  • falhas;
  • tentativas;
  • dead-letter;
  • latência entre occurred e published;
  • duração do lote.

Veja Métricas Prometheus no Node.js.

Alerta principal

A idade do evento mais antigo costuma ser mais útil que apenas a quantidade. Uma fila pequena pode estar travada por horas.

Logs

Registre event ID, type, aggregate, tentativa e resultado. Não registre payload completo por padrão.

Testes

Cubra:

  • rollback da transação;
  • evento gravado junto com o agregado;
  • publicação bem-sucedida;
  • falha antes da publicação;
  • falha após publicação e antes da marcação;
  • duplicata no consumidor;
  • SKIP LOCKED com vários workers;
  • backoff;
  • dead-letter;
  • ordem por agregado.

Teste de atomicidade

test('não grava outbox quando pedido falha', async () => {
  await assert.rejects(() => createInvalidOrder(input));

  const events = await findOutboxByAggregate(input.externalId);
  assert.equal(events.length, 0);
});

Teste de duplicata

Publique o mesmo event ID duas vezes e confirme que o consumidor produz o efeito apenas uma vez.

Erros comuns

  • Publicar dentro da transação: locks ficam abertos durante I/O.
  • Marcar antes de publicar: mensagens podem ser perdidas.
  • Novo ID no retry: consumidor não detecta duplicata.
  • Consumidor não idempotente: efeitos duplicam.
  • Sem índice parcial: polling fica lento.
  • Retry infinito: evento inválido bloqueia o fluxo.
  • Sem limpeza: tabela cresce indefinidamente.

Boas práticas

  • Grave negócio e evento na mesma transação.
  • Use ID estável.
  • Implemente consumidor idempotente.
  • Use lote e SKIP LOCKED.
  • Adote lease para recuperação.
  • Classifique erros.
  • Versione eventos.
  • Preserve ordem por agregado.
  • Monitore idade da fila.
  • Limpe dados em lotes.

Conclusão

O Outbox Pattern no Node.js elimina a janela em que o banco confirma uma operação, mas o evento correspondente se perde. A mensagem é persistida junto com o estado de negócio e publicada depois por um relay confiável.

A entrega pode ocorrer mais de uma vez, portanto IDs estáveis e consumidores idempotentes são obrigatórios. Com SKIP LOCKED, backoff, dead-letter e métricas de idade, a outbox cria uma ponte robusta entre transações locais e comunicação distribuída.

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