Outbox Pattern

Como garantir a publicação confiável de eventos em sistemas distribuídos usando transactional outbox.

Que problema resolve

Em sistemas baseados em eventos, um microsserviço precisa fazer duas coisas quando processa uma operação: salvar dados no seu banco de dados e publicar um evento para notificar outros serviços. O problema é que essas são duas operações em dois sistemas diferentes (banco de dados e message broker), e não existe uma transação atômica que cubra ambas.

O problema do dual write

Serviço de Pedidos:
  1. Salvar pedido no PostgreSQL  ✅
  2. Publicar "PedidoCriado" no Kafka  ❌ (falha de rede)

Resultado: O pedido existe no banco de dados,
mas nenhum outro serviço ficou sabendo.

Esse cenário se chama dual write (escrita dupla) e pode causar:

  • Dados salvos, evento perdido: O pedido existe, mas o estoque nunca foi reservado
  • Evento publicado, dados não salvos: Outros serviços reagem a um pedido que não existe
  • Inconsistência silenciosa: O sistema parece funcionar, mas os dados entre serviços divergem

Tentar resolver isso com “publicar primeiro, salvar depois” apenas inverte o problema. E usar transações distribuídas (2PC) introduz latência e pontos de falha.

Cenários reais onde acontece

CenárioConsequência sem Outbox
Criar pedido + notificar estoqueEstoque não é reservado, vende-se produto esgotado
Processar pagamento + notificar enviosPagamento cobrado, mas envio nunca é agendado
Atualizar perfil + notificar serviçosDados dessincronizados entre serviços
Concluir passo N da saga + publicar eventoSaga fica travada em passo intermediário

Como funciona

O Outbox Pattern resolve o dual write usando uma única transação de banco de dados para ambas as operações. Em vez de publicar o evento diretamente no broker, o serviço grava o evento em uma tabela outbox dentro da mesma transação que salva os dados de negócio.

Fluxo básico

┌─────────────────────────────────────────────┐
│  Transação de banco de dados (atômica)      │
│                                             │
│  1. INSERT INTO pedidos (...)               │
│  2. INSERT INTO outbox (evento, payload...) │
│                                             │
│  COMMIT                                     │
└─────────────────────────────────────────────┘

         │  (processo separado)

┌─────────────────────┐     ┌──────────────┐
│  Relay / Publisher   │────►│  Kafka /     │
│  (lê tabela outbox)  │     │  RabbitMQ    │
└─────────────────────┘     └──────────────┘

A chave é que ambas as escritas (dados de negócio + evento) acontecem na mesma transação de banco de dados. Se a transação falhar, nenhuma das duas é persistida. Se tiver sucesso, ambas são persistidas. Não há janela de inconsistência.

Estrutura da tabela outbox

CREATE TABLE outbox (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type  VARCHAR(255) NOT NULL,  -- "Pedido", "Pago"
  aggregate_id    VARCHAR(255) NOT NULL,  -- ID del agregado
  event_type      VARCHAR(255) NOT NULL,  -- "PedidoCreado"
  payload         JSONB NOT NULL,         -- datos del evento
  created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
  published_at    TIMESTAMP NULL          -- NULL = pendiente
);

-- Índice para el publisher
CREATE INDEX idx_outbox_pending
  ON outbox (created_at)
  WHERE published_at IS NULL;

Exemplo de uso em código

// Dentro de una transacción de base de datos
BEGIN;

  -- 1. Operación de negocio
  INSERT INTO pedidos (id, cliente_id, total, estado)
  VALUES ('ped-001', 'cli-789', 150.00, 'confirmado');

  INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio)
  VALUES ('ped-001', 'prod-42', 2, 75.00);

  -- 2. Evento en la tabla outbox (misma transacción)
  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
  VALUES (
    'Pedido',
    'ped-001',
    'PedidoConfirmado',
    '{"pedidoId": "ped-001", "clienteId": "cli-789", "total": 150.00}'
  );

COMMIT;
-- Ambas escrituras son atómicas: o se guardan las dos, o ninguna

Estratégias de publicação

Há duas formas principais de ler a tabela outbox e publicar os eventos no broker:

Polling Publisher (Publicador por sondagem)

Um processo periódico consulta a tabela outbox em busca de eventos não publicados, os envia ao broker e os marca como publicados.

┌──────────────┐                    ┌──────────┐
│   Outbox     │  SELECT * FROM     │ Polling  │
│   Table      │◄─── outbox WHERE ──│ Publisher│
│              │  published_at NULL  │          │
└──────────────┘                    └────┬─────┘

                                   Publicar evento


                                   ┌──────────┐
                                   │  Broker  │
                                   └──────────┘

Vantagens: Simples de implementar, não requer infraestrutura adicional. Desvantagens: Latência (depende do intervalo de polling), carga no banco de dados.

Implementação típica do polling:

// Cada 500ms (configurable)
loop {
  eventos = SELECT * FROM outbox
            WHERE published_at IS NULL
            ORDER BY created_at
            LIMIT 100
            FOR UPDATE SKIP LOCKED;  -- evita conflictos entre publishers

  for evento in eventos {
    broker.publish(evento.event_type, evento.payload);

    UPDATE outbox
    SET published_at = NOW()
    WHERE id = evento.id;
  }
}

Change Data Capture (CDC)

Um conector CDC (como o Debezium) monitora o log de transações do banco de dados e captura automaticamente as inserções na tabela outbox.

┌──────────────┐                    ┌──────────┐
│  PostgreSQL  │  WAL / binlog      │ Debezium │
│  Transaction │──────────────────► │  (CDC)   │
│  Log         │                    └────┬─────┘
└──────────────┘                         │
                                    Publicar evento


                                    ┌──────────┐
                                    │  Kafka   │
                                    └──────────┘

Vantagens: Latência muito baixa (quase real-time), não gera carga adicional com queries. Desvantagens: Requer infraestrutura adicional (Debezium, Kafka Connect), mais complexo de operar.

Comparação de estratégias

AspectoPollingCDC
Latência100ms - 5s (configurável)< 100ms (quase real-time)
ComplexidadeBaixa (um cron ou processo simples)Alta (Debezium + Kafka Connect)
Carga no BDQueries periódicasLê do WAL, sem queries extras
InfraestruturaMínimaDebezium, Kafka Connect, Zookeeper
EscalabilidadeLimitada pelo BDAlta (CDC é muito eficiente)
Recomendado paraVolume baixo-médioVolume alto, baixa latência

Garantias de entrega

O Outbox Pattern garante at-least-once delivery (entrega ao menos uma vez). Isso significa que um evento pode ser publicado mais de uma vez. Por isso, os consumidores devem ser idempotentes.

Cenário de duplicação:
  1. Publisher lê evento da outbox
  2. Publisher publica evento no Kafka  ✅
  3. Publisher falha antes de marcar como publicado  ❌
  4. Publisher reinicia e publica novamente o mesmo evento

Solução: O consumidor detecta o duplicado pelo ID do evento
e o ignora (Idempotency Pattern).

Limpeza da tabela outbox

A tabela outbox cresce continuamente. Você precisa de uma estratégia de limpeza:

-- Opción 1: Borrar eventos publicados hace más de 7 días
DELETE FROM outbox
WHERE published_at IS NOT NULL
  AND published_at < NOW() - INTERVAL '7 days';

-- Opción 2: Particionado por fecha (más eficiente)
-- Crear particiones mensuales y dropear particiones antiguas
CREATE TABLE outbox (
  ...
) PARTITION BY RANGE (created_at);

-- Opción 3: Mover a tabla de archivo antes de borrar
INSERT INTO outbox_archive SELECT * FROM outbox
WHERE published_at < NOW() - INTERVAL '30 days';

Vantagens

  • Consistência garantida: Dados e eventos são gravados na mesma transação, eliminando o dual write
  • Sem transações distribuídas: Você não precisa de 2PC nem XA transactions
  • Auditoria natural: A tabela outbox funciona como log de todos os eventos publicados
  • Resiliência: Se o broker estiver fora do ar, os eventos se acumulam e são publicados quando ele voltar
  • Desacoplamento temporal: O serviço não depende da disponibilidade do broker
  • Compatível com qualquer banco de dados relacional: Você só precisa de uma tabela adicional

Trade-offs / Desvantagens

  • Latência adicional: Os eventos não são publicados instantaneamente (especialmente com polling)
  • Complexidade operacional: Você precisa manter o publisher ou o conector CDC
  • A tabela outbox cresce: Você precisa de uma estratégia de limpeza periódica
  • At-least-once: Os consumidores devem lidar com duplicados (idempotência obrigatória)
  • Acoplamento com o banco de dados: O CDC depende de features específicas do engine (WAL, binlog)
  • Overhead na escrita: Cada operação grava uma linha adicional na tabela outbox

Quando usar

  • Sistemas event-driven onde a confiabilidade dos eventos é crítica
  • Microsserviços que precisam notificar mudanças a outros serviços de forma confiável
  • Quando você implementa o Saga Pattern e precisa garantir que os eventos de cada passo sejam publicados
  • Sistemas financeiros ou de e-commerce onde perder um evento tem impacto no negócio
  • Quando você já usa um banco de dados relacional e quer evitar transações distribuídas
  • Fluxos onde a consistência eventual é aceitável, mas a perda de eventos não

Quando evitar

  • Sistemas que exigem publicação de eventos em tempo real estrito (microssegundos)
  • Quando você usa um banco de dados que não suporta transações ACID (alguns NoSQL)
  • Aplicações simples onde publicar diretamente no broker é suficiente
  • Sistemas com volume de eventos muito baixo, onde a complexidade não se justifica
  • Quando você já tem Event Sourcing implementado (o event store já é a sua “outbox”)

Tecnologias e implementações comuns

CategoriaOpções
CDCDebezium, Maxwell (MySQL), AWS DMS
BrokersApache Kafka, RabbitMQ, Amazon SNS/SQS, Azure Service Bus
Bancos de dadosPostgreSQL (WAL), MySQL (binlog), SQL Server (CT)
FrameworksEventuate Tram, MassTransit Outbox (.NET), Wolverine (.NET)
ConectoresKafka Connect, Debezium Server
LimpezaCron jobs, pg_partman (particionamento por data)

Relação com outros padrões

O Outbox Pattern é uma peça fundamental na infraestrutura de sistemas event-driven:

┌─────────────────────────────────────────────────┐
│         Outbox en el ecosistema                  │
│                                                  │
│  ┌──────────┐  necesita   ┌──────────────────┐  │
│  │  Saga    │ ──────────► │  Outbox Pattern  │  │
│  │ Pattern  │  publicar   │  (este patrón)   │  │
│  └──────────┘  eventos    └────────┬─────────┘  │
│                                    │             │
│                              entrega al          │
│                              menos una vez       │
│                                    │             │
│                                    ▼             │
│                            ┌──────────────┐      │
│                            │ Idempotency  │      │
│                            │   Pattern    │      │
│                            └──────────────┘      │
│                                                  │
│  ┌──────────────┐  usa     ┌──────────────┐     │
│  │ Event-Driven │ ────────►│    Outbox     │     │
│  │ Architecture │  para    │   (confiable) │     │
│  └──────────────┘  eventos └──────────────┘     │
└─────────────────────────────────────────────────┘
  • Saga Pattern: Cada passo de uma saga precisa publicar eventos de forma confiável. O Outbox garante que nenhum evento se perca entre os passos
  • Idempotency Pattern: Como o Outbox garante at-least-once, os consumidores devem ser idempotentes para lidar com duplicados
  • Event-Driven Architecture: O Outbox é a infraestrutura que torna confiável a publicação de eventos em uma arquitetura event-driven
  • Circuit Breaker: Se o publisher da outbox não conseguir conectar ao broker, o circuit breaker evita retentativas infinitas
  • DDD: Os Domain Events que cada bounded context gera são publicados de forma confiável através da outbox

Erros comuns

ErroConsequênciaSolução
Não limpar a tabela outboxTabela cresce sem limite, queries lentasCron de limpeza ou particionamento
Publisher sem idempotênciaEventos duplicados no brokerConsumidores idempotentes + dedup
Payload grande demaisTabela outbox ocupa muito espaçoSalvar apenas IDs, consumidor consulta os detalhes
Um único publisherGargalo em alto volumeMúltiplos publishers com SKIP LOCKED
Não monitorar o lagEventos se acumulam sem serem detectadosAlertas por tamanho da fila pendente

Próximos passos

Com a publicação confiável de eventos resolvida, o próximo desafio é proteger seus serviços quando as dependências falham. O Circuit Breaker é o padrão que evita que um serviço fora do ar arraste todo o sistema. Explore também o Idempotency Pattern para garantir que os consumidores dos seus eventos lidem corretamente com os duplicados.