Padrão outbox no PostgreSQL para integrações de API confiáveis
Aprenda o padrão outbox para armazenar eventos no PostgreSQL e entregá-los a APIs de terceiros com retries, ordenação e deduplicação.

Por que integrações falham mesmo quando seu app funciona
É comum ver uma ação “bem-sucedida” no seu app enquanto a integração por trás dela falha silenciosamente. Sua escrita no banco é rápida e confiável. Uma chamada para uma API de terceiros não é. Isso cria dois mundos distintos: seu sistema diz que a alteração ocorreu, mas o sistema externo nunca foi avisado.
Um exemplo típico: um cliente faz um pedido, seu app o salva no PostgreSQL e então tenta notificar um provedor de frete. Se o provedor fica lento por 20 segundos e sua requisição desiste, o pedido continua real, mas o envio nunca foi criado.
Os usuários percebem isso como comportamento confuso e inconsistente. Eventos perdidos parecem “nada aconteceu”. Eventos duplicados parecem “por que fui cobrado duas vezes?”. As equipes de suporte também têm dificuldade porque é difícil saber se o problema foi do seu app, da rede ou do parceiro.
Retries ajudam, mas sozinhos não garantem correção. Se você reenviar após um timeout, pode mandar o mesmo evento duas vezes porque não sabe se o parceiro recebeu a primeira requisição. Se reenviar fora de ordem, pode mandar “Pedido enviado” antes de “Pedido pago”.
Esses problemas normalmente vêm da concorrência normal: vários workers processando em paralelo, vários servidores escrevendo ao mesmo tempo e filas de “melhor esforço” onde o timing muda sob carga. Os modos de falha são previsíveis: APIs caem ou ficam lentas, redes perdem requisições, processos travam no momento errado e retries criam duplicatas quando nada força idempotência.
O padrão outbox existe porque essas falhas são normais.
O que é o padrão outbox em termos simples
O padrão outbox é direto: quando seu app faz uma mudança importante (como criar um pedido), ele também grava um pequeno registro "evento a enviar" em uma tabela do banco, na mesma transação. Se o commit do banco for bem-sucedido, você sabe que os dados de negócio e o registro de evento existem juntos.
Depois disso, um worker separado lê a tabela outbox e entrega esses eventos para APIs de terceiros. Se uma API está lenta, fora do ar ou dá timeout, a requisição do usuário continua bem porque não está esperando pela chamada externa.
Isso evita os estados embaraçosos que você obtém quando chama uma API dentro do handler da requisição:
- O pedido é salvo, mas a chamada à API falha.
- A chamada à API tem sucesso, mas seu app trava antes de salvar o pedido.
- O usuário tenta novamente, e você envia a mesma coisa duas vezes.
O padrão outbox ajuda principalmente com eventos perdidos, falhas parciais (banco ok, API externa não), envios duplos acidentais e retries mais seguros (você pode tentar de novo mais tarde sem adivinhar).
Não resolve tudo. Se seu payload estiver errado, suas regras de negócio estiverem erradas ou a API de terceiros rejeitar os dados, você ainda precisa de validação, bom tratamento de erros e um jeito de inspecionar e corrigir eventos falhados.
Projetando uma tabela outbox no PostgreSQL
Uma boa tabela outbox é propositalmente sem glamour. Deve ser fácil de escrever, fácil de ler e difícil de usar errado.
Aqui está um schema prático que você pode adaptar:
create table outbox_events (
id bigserial primary key,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
status text not null default 'pending',
created_at timestamptz not null default now(),
available_at timestamptz not null default now(),
attempts int not null default 0,
locked_at timestamptz,
locked_by text,
meta jsonb not null default '{}'::jsonb
);
Escolhendo um ID
Usar bigserial (ou bigint) mantém a ordenação simples e índices rápidos. UUIDs são ótimos para unicidade entre sistemas, mas não ordenam por criação, o que pode tornar o polling menos previsível e deixar índices mais pesados.
Um compromisso comum: mantenha id como bigint para ordenação e adicione um event_uuid separado se precisar de um identificador estável para compartilhar entre serviços.
Índices que importam
Seu worker vai consultar os mesmos padrões o dia todo. A maioria dos sistemas precisa de:
- Um índice como
(status, available_at, id)para buscar os próximos eventos pendentes em ordem. - Um índice em
(locked_at)se você planeja expirar locks antigos. - Um índice como
(aggregate_id, id)se você às vezes entrega por agregado em ordem.
Mantenha payloads estáveis
Mantenha payloads pequenos e previsíveis. Armazene o que o receptor realmente precisa, não sua linha inteira. Adicione uma versão explícita (por exemplo, em meta) para evoluir campos com segurança.
Use meta para roteamento e contexto de debugging como tenant ID, correlation ID, trace ID e uma chave de deduplicação. Esse contexto extra compensa quando o suporte precisa responder “o que aconteceu com este pedido?”.
Como armazenar eventos com segurança junto da escrita de negócio
A regra mais importante é simples: grave os dados de negócio e o evento outbox na mesma transação do banco. Se a transação commitar, ambos existem. Se der rollback, nenhum existe.
Exemplo: um cliente faz um pedido. Em uma transação você insere a linha do pedido, os itens e uma linha no outbox como order.created. Se qualquer etapa falhar, você não quer um evento “criado” escapando para o mundo.
Um evento ou vários?
Comece com um evento por ação de negócio quando puder. É mais fácil de raciocinar e mais barato de processar. Separe em múltiplos eventos apenas quando consumidores diferentes precisarem de timings ou payloads distintos (por exemplo, order.created para fulfillment e payment.requested para cobrança). Gerar muitos eventos por um clique aumenta retries, problemas de ordenação e manuseio de duplicatas.
Que payload armazenar?
Normalmente você escolhe entre:
- Snapshot: armazene campos-chave como estavam no momento da ação (total do pedido, moeda, customer ID). Isso evita leituras extras depois e mantém a mensagem estável.
- ID de referência: armazene apenas o ID do pedido e deixe o worker carregar detalhes depois. Isso mantém o outbox enxuto, mas adiciona leituras e pode mudar se o pedido for editado.
Um meio-termo prático é identificadores mais um pequeno snapshot de valores críticos. Isso ajuda receptores a agir rápido e facilita o debug.
Mantenha a transação enxuta. Não chame APIs de terceiros dentro da mesma transação.
Entregando eventos para APIs de terceiros: o loop do worker
Uma vez que os eventos estão no outbox, você precisa de um worker que os leia e chame a API de terceiros. Essa é a parte que transforma o padrão em uma integração confiável.
Polling costuma ser a opção mais simples. LISTEN/NOTIFY pode reduzir latência, mas adiciona partes móveis e ainda precisa de fallback quando notificações se perdem ou o worker reinicia. Para a maioria das equipes, polling constante com um lote pequeno é mais fácil de operar e depurar.
Como claimar linhas com segurança
O worker deve claimar linhas para que dois workers nunca processem o mesmo evento ao mesmo tempo. No PostgreSQL, a abordagem comum é selecionar um lote usando locks de linha e SKIP LOCKED, então marcá-los como em progresso.
Um fluxo de status prático é:
pending: pronto para enviarprocessing: bloqueado por um worker (uselocked_byelocked_at)sent: entregue com sucessofailed: parado após máximo de tentativas (ou movido para revisão manual)
Mantenha lotes pequenos para não sobrecarregar o banco. Um lote de 10 a 100 linhas, rodando a cada 1 a 5 segundos, é um ponto de partida comum.
Quando uma chamada tem sucesso, marque a linha como sent. Quando falha, incremente attempts, defina available_at para um horário futuro (backoff), limpe o lock e retorne para pending.
Logs que ajudam (sem vazar segredos)
Bons logs tornam falhas acionáveis. Logue o id do outbox, tipo do evento, nome do destino, contagem de tentativas, tempos e status HTTP ou classe de erro. Evite bodies de requisição, headers de autenticação e respostas completas. Se precisar de correlação, armazene um request ID seguro ou um hash em vez do payload bruto.
Regras de ordenação que funcionam em sistemas reais
Muitas equipes começam com “envie eventos na mesma ordem em que os criamos.” O problema é que “a mesma ordem” raramente é global. Se você forçar uma fila global, um cliente lento ou uma API instável pode segurar todo mundo.
Uma regra prática: preserve ordem por grupo, não para todo o sistema. Escolha uma chave de agrupamento que reflita como o mundo externo pensa sobre seus dados — como customer_id, account_id ou um aggregate_id como order_id. Então garanta ordenação dentro de cada grupo enquanto entrega muitos grupos em paralelo.
Workers paralelos sem quebrar a ordem
Execute múltiplos workers, mas garanta que dois workers não processem o mesmo grupo ao mesmo tempo. A abordagem usual é sempre entregar o evento pendente mais antigo para um determinado aggregate_id e permitir paralelismo entre diferentes agregados.
Mantenha as regras de claim simples:
- Apenas entregue o evento pendente mais antigo por grupo.
- Permita paralelismo entre grupos, não dentro de um grupo.
- Reserve um evento, envie-o, atualize o status e siga em frente.
Quando um evento bloqueia o resto
Mais cedo ou mais tarde, um evento “venenoso” vai falhar por horas (payload ruim, token revogado, outage do provedor). Se você aplicar ordenação estrita por grupo, eventos posteriores daquele grupo devem esperar, mas outros grupos devem continuar.
Um compromisso viável é limitar retries por evento. Depois disso, marque como failed e pause apenas aquele grupo até alguém corrigir a causa. Assim um cliente quebrado não atrasa todo mundo.
Retries sem piorar a situação
Retries são onde um bom setup de outbox vira algo confiável ou barulhento. O objetivo é simples: tentar de novo quando for provável que funcione e parar rápido quando não for.
Use backoff exponencial e um teto rígido. Por exemplo: 1 minuto, 2 minutos, 4 minutos, 8 minutos e então pare (ou continue com um atraso máximo como 15 minutos). Sempre defina um número máximo de tentativas para que um evento ruim não trave o sistema para sempre.
Nem toda falha deve ser reenviada. Tenha regras claras:
- Reenviar: timeouts de rede, resets de conexão, falhas de DNS e respostas HTTP 429 ou 5xx.
- Não reenviar: HTTP 400 (bad request), 401/403 (problemas de autenticação), 404 (endpoint errado) ou erros de validação detectáveis antes do envio.
Armazene o estado de retry na própria linha do outbox. Incremente attempts, defina available_at para a próxima tentativa e registre um resumo curto e seguro do erro (código de status, classe de erro, mensagem curta). Não armazene payloads completos ou dados sensíveis nos campos de erro.
Rate limits precisam de tratamento especial. Se receber HTTP 429, respeite Retry-After quando existir. Caso contrário, faça backoff mais agressivo para evitar uma tempestade de retries.
Deduplicação e noções básicas de idempotência
Se você quer integrações confiáveis, presuma que o mesmo evento pode ser enviado duas vezes. Um worker pode travar depois da chamada HTTP mas antes de gravar o sucesso. Um timeout pode ocultar um sucesso. Um retry pode se sobrepor a uma tentativa lenta.
O padrão outbox reduz eventos perdidos, mas não evita duplicatas por si só.
A abordagem mais segura é idempotência: entregas repetidas produzem o mesmo resultado que uma entrega única. Ao chamar uma API de terceiros, inclua uma chave de idempotência que permaneça estável para esse evento e esse destino. Muitas APIs aceitam um header; se não, coloque a chave no corpo.
Uma chave simples é destino + ID do evento. Para um evento com ID evt_123, sempre use algo como destA:evt_123.
No seu lado, previna envios duplicados mantendo um log de entregas e aplicando uma regra única como (destination, event_id). Mesmo que dois workers disputem, apenas um poderá criar o registro “estamos enviando isto”.
Webhooks também duplicam
Se você recebe callbacks de webhook (como “entrega confirmada” ou “status atualizado”), trate-os da mesma forma. Provedores fazem retries e você pode ver o mesmo payload várias vezes. Armazene IDs de webhook processados ou calcule um hash estável a partir do ID da mensagem do provedor e rejeite repetições.
Quanto tempo manter dados
Mantenha linhas do outbox até ter registrado sucesso (ou uma falha final aceita). Mantenha logs de entrega por mais tempo, pois são seu rastro de auditoria quando alguém pergunta “enviamos isto?”.
Uma abordagem comum:
- Linhas do outbox: delete ou archive após o sucesso mais uma pequena janela de segurança (dias).
- Logs de entrega: mantenha por semanas ou meses, conforme compliance e necessidades de suporte.
- Chaves de idempotência: mantenha pelo menos enquanto retries podem ocorrer (e mais tempo para duplicatas de webhook).
Passo a passo: implementando o padrão outbox
Decida o que você vai publicar. Mantenha eventos pequenos, focados e fáceis de reprojetar. Uma boa regra é um fato de negócio por evento, com dados suficientes para o receptor agir.
Construir a base
Escolha nomes de evento claros (por exemplo, order.created, order.paid) e version a schema do payload (como v1, v2). Versionamento permite adicionar campos depois sem quebrar consumidores antigos.
Crie sua tabela outbox no PostgreSQL e adicione índices para as consultas que seu worker fará com mais frequência, especialmente (status, available_at, id).
Atualize o fluxo de escrita para que a mudança de negócio e o insert no outbox aconteçam na mesma transação. Essa é a garantia central.
Adicionar entrega e controle
Um plano simples de implementação:
- Defina tipos de evento e versões de payload que você dará suporte a longo prazo.
- Crie a tabela outbox e os índices.
- Insira uma linha no outbox junto com a mudança principal de dados.
- Construa um worker que claima linhas, envia para a API de terceiros e então atualiza o status.
- Adicione agendamento de retry com backoff e um estado
failedquando as tentativas acabarem.
Adicione métricas básicas para notar problemas cedo: lag (idade do evento não enviado mais antigo), taxa de envio e taxa de falhas.
Um exemplo simples: enviar eventos de pedido para serviços externos
Um cliente faz um pedido no seu app. Duas coisas precisam acontecer fora do seu sistema: o provedor de cobrança deve cobrar o cartão e o provedor de frete deve criar um envio.
Com o padrão outbox, você não chama essas APIs dentro da requisição de checkout. Em vez disso, salva o pedido e uma linha no outbox na mesma transação PostgreSQL, assim você nunca fica com “pedido salvo, mas sem notificação” (ou o inverso).
Uma linha típica de outbox para um evento de pedido pode incluir um aggregate_id (o ID do pedido), um event_type como order.created e um payload JSONB com totais, itens e detalhes de destino.
Um worker pega linhas pendentes e chama os serviços externos (em ordem definida ou emitindo eventos separados como payment.requested e shipment.requested). Se um provedor estiver fora, o worker registra a tentativa, agenda a próxima tentativa movendo available_at para o futuro e segue em frente. O pedido ainda existe e o evento será reenviado depois sem bloquear novos checkouts.
A ordenação geralmente é “por pedido” ou “por cliente”. Faça com que eventos com o mesmo aggregate_id sejam processados um a um para que order.paid nunca chegue antes de order.created.
Deduplicação evita cobranças duplas ou criação de dois envios. Envie uma chave de idempotência quando o terceiro suportar e mantenha um registro de entrega por destino para que um retry após timeout não dispare uma segunda ação.
Verificações rápidas antes de colocar em produção
Antes de confiar uma integração para mover dinheiro, notificar clientes ou sincronizar dados, teste as bordas: crashes, retries, duplicatas e múltiplos workers.
Checagens que pegam falhas comuns:
- Confirme que a linha do outbox é criada na mesma transação que a mudança de negócio.
- Verifique que o remetente é seguro para rodar em múltiplas instâncias. Dois workers não devem enviar o mesmo evento simultaneamente.
- Se a ordenação importa, defina a regra em uma frase e imponha-a com uma chave estável.
- Para cada destino, decida como prevenir duplicatas e como provar “enviamos isto”.
- Defina a saída: após N tentativas, mova o evento para
failed, guarde o último resumo de erro e ofereça uma ação simples de reprocessamento.
Um cheque de realidade: Stripe pode aceitar uma requisição mas seu worker travar antes de salvar o sucesso. Sem idempotência, um retry pode causar ação dupla. Com idempotência e um registro de entrega salvo, o retry fica seguro.
Próximos passos: implantar isso sem atrapalhar seu app
O rollout é onde projetos outbox normalmente prosperam ou estagnam. Comece pequeno para ver o comportamento real sem colocar toda a camada de integrações em risco.
Comece com uma integração e um tipo de evento. Por exemplo, envie apenas order.created para uma API de fornecedor enquanto o resto fica como está. Isso dá uma linha de base limpa para throughput, latência e taxa de falhas.
Torne problemas visíveis cedo. Adicione dashboards e alertas para lag do outbox (quantos eventos estão esperando e qual a idade do mais antigo) e taxa de falhas (quantos estão presos em retry). Se você conseguir responder “estamos atrasados agora?” em 10 segundos, pegará problemas antes dos usuários.
Tenha um plano seguro de reprocessamento antes do primeiro incidente. Decida o que “reprocessar” significa: reenviar o mesmo payload, reconstruir o payload a partir dos dados atuais ou enviar para revisão manual. Documente quais casos são seguros para reenvio e quais precisam de verificação humana.
Se você está construindo isso com uma plataforma no-code como AppMaster (appmaster.io), a estrutura continua a mesma: grave seus dados de negócio e uma linha outbox juntos no PostgreSQL, então rode um processo backend separado para entregar, reencaminhar e marcar eventos como enviados ou falhados.
FAQ
Use o padrão outbox quando uma ação do usuário atualiza seu banco de dados e precisa acionar trabalho em outro sistema. É especialmente útil quando timeouts, redes instáveis ou quedas de terceiros podem criar situações do tipo “salvo no nosso app, faltando no deles”.
Gravar a linha de negócio e a linha do outbox na mesma transação do banco dá uma garantia simples: ou ambos existem, ou nenhum existe. Isso evita falhas parciais como “a chamada à API teve sucesso mas o pedido não foi salvo” ou “pedido salvo mas a chamada à API nunca ocorreu”.
Um bom padrão é ter id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, além de campos de bloqueio como locked_at e locked_by. Com isso você mantém agendamento de retry e concorrência segura sem complicar excessivamente a tabela.
Um índice comum e útil é (status, available_at, id), assim os workers conseguem buscar rapidamente o próximo lote de eventos enviáveis em ordem. Só adicione índices extras quando você realmente consultar por esses campos, porque índices a mais tornam inserts mais lentos.
Polling é a abordagem mais simples e previsível para a maioria das equipes. Comece com lotes pequenos e um intervalo curto, depois ajuste conforme carga e atraso; você pode otimizar depois, mas um loop simples é mais fácil de depurar quando algo falha.
Claim (reservar) linhas usando locks a nível de linha para que dois workers não processem o mesmo evento simultaneamente, tipicamente com SKIP LOCKED. Em seguida marque a linha como processing com timestamp e ID do worker, envie, e então marque como sent ou retorne a pending com available_at no futuro.
Use backoff exponencial com um limite rígido de tentativas e reenvie apenas falhas que provavelmente são temporárias. Timeouts, erros de rede e HTTP 429/5xx são candidatos a retry; erros de validação e a maioria dos 4xx devem ser tratados como finais até você corrigir dados ou configuração.
Não conte com entrega exatamente-uma — assuma que duplicatas ainda podem ocorrer, especialmente se um worker cair depois da chamada HTTP mas antes de salvar o sucesso. Use uma chave de idempotência estável por destino e evento, e mantenha um registro de entrega com uma restrição única (destination, event_id) para que races não criem dois envios.
Preserve ordem dentro de um grupo, não globalmente. Use uma chave de agrupamento como aggregate_id (ID do pedido) ou customer_id, processe um evento por vez por grupo e permita paralelismo entre grupos para que um cliente lento não bloqueie todo mundo.
Marque o evento como failed após o número máximo de tentativas, mantenha um resumo curto e seguro do erro e pare de processar eventos posteriores do mesmo grupo até que alguém corrija a causa raiz. Isso contém o impacto e evita retries infinitos enquanto o resto do sistema segue funcionando.


