---
title: "Outbox Pattern em Go: Eventos Confiáveis sem Perder Mensagens"
url: "https://golang.com.br/blog/outbox-pattern-go-eventos-confiaveis/"
markdown_url: "https://golang.com.br/blog/outbox-pattern-go-eventos-confiaveis.MD"
description: "Aprenda outbox pattern em Go para publicar eventos sem perder mensagens: transação com PostgreSQL, publisher, retry, idempotência e observabilidade."
date: "2026-05-24"
author: "Golang Brasil"
---

# Outbox Pattern em Go: Eventos Confiáveis sem Perder Mensagens

Aprenda outbox pattern em Go para publicar eventos sem perder mensagens: transação com PostgreSQL, publisher, retry, idempotência e observabilidade.


Outbox pattern em Go resolve um problema que aparece cedo em APIs de produção: gravar uma mudança no banco e publicar um evento para outro sistema sem criar uma janela de perda. Parece simples. Um pedido é pago, a API atualiza a tabela `orders` e publica `OrderPaid` no RabbitMQ, Kafka, NATS, SQS ou outro broker. Mas o que acontece se o banco confirma e a publicação falha? E se a publicação acontece, mas o processo cai antes de confirmar a transação? E se o broker fica fora por alguns minutos?

Sem desenho explícito, o time acaba escolhendo entre dois bugs ruins: evento perdido ou efeito duplicado. O outbox pattern troca esse risco por uma regra mais previsível: a mudança de domínio e o registro do evento ficam na **mesma transação do banco**. Depois, um publisher separado lê a tabela de outbox e entrega as mensagens ao broker com retry e observabilidade.

Este guia mostra como aplicar transactional outbox em APIs Go com PostgreSQL, quando usar esse padrão, como modelar a tabela, como escrever o publisher e quais cuidados evitam duplicidade. Se você ainda está escolhendo a fila, leia antes [Mensageria em Go: RabbitMQ, Kafka, NATS ou SQS?](/blog/mensageria-go-rabbitmq-kafka-nats-sqs/). Para o lado do consumidor, combine este artigo com [Idempotência em Go: Retry e Dead-Letter Queue sem Duplicar Trabalho](/blog/idempotencia-retry-dlq-go/).

## O bug clássico: transação e broker separados

Imagine um endpoint `POST /payments/:id/confirm`:

```go
func ConfirmPayment(ctx context.Context, db *sql.DB, broker Broker, paymentID string) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    if _, err := tx.ExecContext(ctx, `UPDATE payments SET status = 'paid' WHERE id = $1`, paymentID); err != nil {
        return err
    }

    if err := tx.Commit(); err != nil {
        return err
    }

    return broker.Publish(ctx, "payment.paid", PaymentPaid{PaymentID: paymentID})
}
```

O código parece correto, mas existe uma falha entre `Commit` e `Publish`. Se o processo cair nesse intervalo, o pagamento fica marcado como pago e nenhum consumidor recebe o evento. O envio de e-mail, a nota fiscal, a atualização do data warehouse ou o antifraude podem nunca saber da mudança.

Inverter a ordem também não resolve. Se publicar antes do commit, o consumidor pode reagir a um evento cuja transação foi revertida. Em sistemas distribuídos, essa janela é pequena, mas suficiente para virar incidente.

## A ideia do outbox pattern

O outbox pattern cria uma tabela de saída dentro do mesmo banco da aplicação. Em vez de publicar diretamente no broker durante o caso de uso, você grava uma linha em `outbox_events` dentro da mesma transação que muda o estado do domínio.

O fluxo fica assim:

1. A API inicia uma transação.
2. Atualiza as tabelas de negócio.
3. Insere um evento pendente na tabela de outbox.
4. Faz commit.
5. Um publisher em background busca eventos pendentes e publica no broker.
6. Depois da confirmação, marca o evento como publicado.

Se a aplicação cair antes do commit, nem a mudança nem o evento aparecem. Se cair depois do commit, o evento continua na tabela e será publicado na próxima rodada. O sistema passa a ter uma fonte durável de eventos pendentes.

## Modelagem da tabela de outbox

Uma tabela simples já cobre muitos produtos:

```sql
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,
    status TEXT NOT NULL DEFAULT 'pending',
    attempts INT NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

CREATE INDEX outbox_events_pending_idx
    ON outbox_events (next_attempt_at, created_at)
    WHERE status = 'pending';
```

`aggregate_type` e `aggregate_id` ajudam a depurar e particionar eventos por entidade. `event_type` define o contrato sem depender do nome da tabela. `payload` guarda os dados necessários para consumidores externos. `attempts` e `next_attempt_at` permitem retry com backoff sem travar o publisher.

Evite colocar segredo no payload. Tokens, senhas, documentos sensíveis e dados pessoais desnecessários devem ficar fora do evento. Publique identificadores e campos mínimos para o consumidor buscar o que precisa com autorização adequada.

## Gravando o evento na mesma transação

No caso de uso, a inserção do outbox vira parte normal da transação:

```go
type PaymentPaid struct {
    PaymentID string `json:"payment_id"`
    OrderID   string `json:"order_id"`
    PaidAt    string `json:"paid_at"`
}

func ConfirmPayment(ctx context.Context, db *sql.DB, input ConfirmPaymentInput) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    if _, err := tx.ExecContext(ctx, `
        UPDATE payments
           SET status = 'paid', paid_at = now()
         WHERE id = $1 AND status = 'pending'
    `, input.PaymentID); err != nil {
        return err
    }

    payload, err := json.Marshal(PaymentPaid{
        PaymentID: input.PaymentID,
        OrderID:   input.OrderID,
        PaidAt:    time.Now().UTC().Format(time.RFC3339),
    })
    if err != nil {
        return err
    }

    if _, err := tx.ExecContext(ctx, `
        INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
        VALUES ($1, 'payment', $2, 'payment.paid', $3)
    `, uuid.NewString(), input.PaymentID, payload); err != nil {
        return err
    }

    return tx.Commit()
}
```

O ponto importante não é a biblioteca, e sim a propriedade: o banco confirma a mudança e o evento juntos. Se uma parte falha, a outra também falha.

## Publisher em background

O publisher pode rodar no mesmo binário da API, em um worker separado ou em um job dedicado. Em produção, separar o worker costuma facilitar escala, deploy e observabilidade. O loop básico faz quatro coisas: buscar lote, publicar, marcar sucesso e reagendar falha.

Com PostgreSQL, `FOR UPDATE SKIP LOCKED` ajuda quando há mais de um publisher concorrente:

```sql
SELECT id, event_type, payload
  FROM outbox_events
 WHERE status = 'pending'
   AND next_attempt_at <= now()
 ORDER BY created_at
 LIMIT 100
 FOR UPDATE SKIP LOCKED;
```

Esse select deve rodar dentro de uma transação curta. Cada worker pega linhas sem disputar as mesmas mensagens. Depois de publicar com sucesso, marque `status = 'published'` e preencha `published_at`. Se falhar por erro temporário, incremente `attempts` e defina `next_attempt_at` com backoff. Se falhar por payload inválido ou contrato quebrado, mova para um status como `failed` e alerte o time.

Não durma dentro de uma transação aberta esperando o broker voltar. Transação longa segura locks, pressiona vacuum e atrapalha o banco. O publisher deve falhar rápido, reagendar e tentar depois.

## Duplicidade ainda pode acontecer

Outbox reduz perda de evento, mas não promete entrega exatamente uma vez. Um publisher pode publicar no broker, cair antes de marcar `published_at` e publicar de novo no próximo ciclo. Por isso, consumidores precisam ser idempotentes.

Use o `id` do evento como chave de idempotência. O consumidor pode registrar eventos processados em uma tabela com chave única, como no guia de [idempotência, retry e DLQ em Go](/blog/idempotencia-retry-dlq-go/). Em Kafka, também pense na chave de partição para preservar ordem por entidade. Em RabbitMQ ou SQS, defina estratégia de DLQ para mensagens que repetem falha.

O contrato mental correto é: outbox evita perder o evento depois do commit; idempotência evita duplicar efeito quando o evento reaparece.

## Observabilidade que importa

Um outbox silencioso vira backlog invisível. Monitore pelo menos:

- Quantidade de eventos pendentes.
- Idade do evento pendente mais antigo.
- Taxa de publicação por `event_type`.
- Falhas por tipo de erro.
- Número de tentativas por evento.
- Tempo entre `created_at` e `published_at`.

Logs estruturados com `slog` ajudam a investigar sem vazar payload inteiro. Registre `event_id`, `event_type`, `aggregate_id`, `attempts` e erro resumido. Para padronizar isso, veja [slog em Go: logging estruturado para produção](/blog/slog-go-logging-estruturado/) e [observabilidade em Go](/tutoriais/go-observability/).

Também vale criar um painel simples: backlog por tipo de evento, idade máxima e erros recentes. Se o outbox cresce por minutos, talvez seja uma instabilidade externa. Se cresce por horas, você tem incidente ou bug de contrato.

## Quando não usar outbox

Outbox é útil, mas não deve virar reflexo automático. Para uma rotina interna simples, um [worker pool em Go](/blog/worker-pool-go-fila-jobs/) pode bastar. Para importações manuais e scripts administrativos, uma tabela de status e retry explícito pode ser mais simples. Para eventos de altíssimo volume, talvez você precise de CDC com Debezium ou pipeline dedicado em vez de polling no banco da aplicação.

Use outbox quando a mudança de banco e o evento externo precisam andar juntos, especialmente em pagamentos, pedidos, assinaturas, faturamento, notificações importantes, webhooks recebidos e integrações entre microsserviços.

## Checklist de produção

Antes de considerar o padrão pronto, revise:

- O evento é gravado na mesma transação da mudança de domínio.
- O publisher tem retry com backoff e não mantém transação longa.
- Consumidores são idempotentes usando `event_id` ou chave equivalente.
- Payloads são versionados e não carregam segredo desnecessário.
- Backlog, idade máxima e falhas geram alerta.
- Existe processo para reprocessar ou cancelar eventos travados.
- O deploy do publisher pode ser pausado sem bloquear a API.

Esse desenho não elimina todas as complexidades de sistemas distribuídos, mas cria uma fronteira clara. A API Go confirma estado e evento juntos. O publisher assume entrega com retry. O consumidor assume idempotência. Cada parte fica mais simples de testar, operar e explicar em revisões de arquitetura ou entrevistas para vagas Go backend.
