Resposta rápida: use o padrão Transactional Outbox quando uma operação precisa alterar o banco de dados e publicar um evento sem correr o risco de salvar apenas um dos dois. Em Kotlin com Spring Boot, grave a entidade de negócio e uma linha na tabela outbox_event dentro da mesma transação PostgreSQL. Depois, um publicador separado lê eventos pendentes, envia ao Kafka e marca o registro como publicado. Essa arquitetura não promete “exactly once” de ponta a ponta: ela evita a perda causada pelo dual write, trabalha normalmente com entrega at-least-once e exige consumidores idempotentes.

Imagine um checkout brasileiro: o pedido foi confirmado no banco, mas o processo caiu antes de publicar PedidoCriado no Kafka. Estoque, antifraude e notificação nunca recebem o evento. Inverter a ordem também não resolve: se o Kafka recebe a mensagem e a transação do pedido falha, outros serviços reagem a algo que oficialmente não existe. O outbox transforma essas duas escritas frágeis em uma escrita atômica no banco e uma entrega assíncrona recuperável.

Este guia implementa o padrão com Kotlin, Spring Boot, PostgreSQL e Kafka, cobre polling com SKIP LOCKED, retries, idempotência, observabilidade, limpeza e a alternativa com CDC/Debezium. Ele complementa nossos conteúdos sobre mensageria com Kafka e RabbitMQ, microsserviços com Kotlin e Testcontainers com PostgreSQL.

O problema de dual write

Um dual write acontece quando o mesmo caso de uso escreve em dois sistemas independentes — por exemplo, PostgreSQL e Kafka — sem uma transação compartilhada.

@Transactional
fun criarPedido(command: CriarPedidoCommand): Pedido {
    val pedido = pedidoRepository.save(
        Pedido.criar(
            clienteId = command.clienteId,
            totalEmCentavos = command.totalEmCentavos,
        )
    )

    kafkaTemplate.send(
        "pedidos.criados",
        pedido.id.toString(),
        PedidoCriado.from(pedido),
    )

    return pedido
}

Apesar de @Transactional, a transação principal desse exemplo é a do banco. O envio ao broker tem seu próprio ciclo de confirmação. Há várias janelas de falha:

  1. o banco confirma e a publicação falha;
  2. a publicação confirma e o banco faz rollback;
  3. o envio demora, a requisição expira e o resultado fica ambíguo;
  4. a aplicação reinicia entre as operações;
  5. um retry publica o mesmo evento duas vezes.

Transações distribuídas do tipo 2PC existem, mas raramente são a escolha prática para bancos e brokers em microsserviços modernos. Elas aumentam acoplamento, complexidade operacional e impacto de indisponibilidade. O outbox aceita que a entrega é assíncrona e cria um registro durável do trabalho pendente.

Como o Transactional Outbox funciona

O fluxo básico tem quatro passos:

  1. a API valida o comando;
  2. a mesma transação grava a entidade de negócio e o evento no outbox;
  3. um publicador busca linhas pendentes e envia ao Kafka;
  4. depois da confirmação, o publicador marca a linha como enviada.
POST /pedidos
    |
    v
[transação PostgreSQL]
  INSERT pedido
  INSERT outbox_event
[commit único]
    |
    v
publicador -> Kafka -> consumidores idempotentes

A garantia importante está no primeiro bloco: ou pedido e outbox_event existem juntos, ou nenhum deles existe. Se Kafka ficar fora do ar, o pedido continua confirmado e o evento permanece pendente para retry.

O padrão é descrito no catálogo de padrões de microsserviços de Chris Richardson como uma forma de publicar eventos de maneira confiável sem 2PC. Para a integração com Kafka, consulte também as documentações oficiais do Spring for Apache Kafka e do Apache Kafka.

Modelando a tabela de outbox

Uma tabela útil precisa guardar identidade, tipo, agregado, payload, estado e informações operacionais:

CREATE TABLE outbox_event (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(150) NOT NULL,
    payload JSONB NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    attempts INTEGER NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ,
    last_error TEXT
);

CREATE INDEX idx_outbox_pending
    ON outbox_event (next_attempt_at, occurred_at)
    WHERE status = 'PENDING';

O id é também o identificador global do evento. Consumidores podem usá-lo para deduplicação. aggregate_id normalmente vira a chave da mensagem no Kafka, preservando a ordem dos eventos do mesmo pedido dentro de uma partição. event_type deve representar um contrato de domínio, como PedidoCriado.v1, e não o nome interno de uma classe que pode mudar em refatorações.

Não grave uma entidade JPA inteira no payload. Publique um contrato explícito, pequeno e versionável. Dados sensíveis, tokens e informações pessoais desnecessárias não pertencem ao evento.

Entidade e contrato em Kotlin

Um modelo JPA simples pode ser escrito assim:

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
import java.util.UUID

@Entity
@Table(name = "outbox_event")
class OutboxEvent(
    @Id
    val id: UUID,

    @Column(name = "aggregate_type", nullable = false)
    val aggregateType: String,

    @Column(name = "aggregate_id", nullable = false)
    val aggregateId: String,

    @Column(name = "event_type", nullable = false)
    val eventType: String,

    @Column(columnDefinition = "jsonb", nullable = false)
    val payload: String,

    @Column(name = "occurred_at", nullable = false)
    val occurredAt: Instant,

    @Enumerated(EnumType.STRING)
    var status: OutboxStatus = OutboxStatus.PENDING,

    var attempts: Int = 0,

    @Column(name = "next_attempt_at", nullable = false)
    var nextAttemptAt: Instant = Instant.now(),

    @Column(name = "published_at")
    var publishedAt: Instant? = null,

    @Column(name = "last_error")
    var lastError: String? = null,
)

enum class OutboxStatus {
    PENDING,
    PUBLISHED,
    DEAD,
}

E o contrato do evento pode ser independente da entidade:

data class PedidoCriadoV1(
    val eventId: UUID,
    val pedidoId: UUID,
    val clienteId: UUID,
    val totalEmCentavos: Long,
    val occurredAt: Instant,
)

Colocar eventId e occurredAt dentro do envelope facilita tracing, auditoria e deduplicação. Em sistemas maiores, também vale adicionar correlationId, causationId, versão do schema e origem.

Gravando pedido e evento na mesma transação

O serviço de aplicação constrói o pedido, salva o contrato serializado e deixa o Spring controlar o commit:

@Service
class CriarPedidoService(
    private val pedidoRepository: PedidoRepository,
    private val outboxRepository: OutboxRepository,
    private val objectMapper: ObjectMapper,
    private val clock: Clock,
) {
    @Transactional
    fun executar(command: CriarPedidoCommand): UUID {
        val agora = clock.instant()
        val pedido = Pedido(
            id = UUID.randomUUID(),
            clienteId = command.clienteId,
            totalEmCentavos = command.totalEmCentavos,
            criadoEm = agora,
        )
        pedidoRepository.save(pedido)

        val evento = PedidoCriadoV1(
            eventId = UUID.randomUUID(),
            pedidoId = pedido.id,
            clienteId = pedido.clienteId,
            totalEmCentavos = pedido.totalEmCentavos,
            occurredAt = agora,
        )

        outboxRepository.save(
            OutboxEvent(
                id = evento.eventId,
                aggregateType = "Pedido",
                aggregateId = pedido.id.toString(),
                eventType = "PedidoCriado.v1",
                payload = objectMapper.writeValueAsString(evento),
                occurredAt = agora,
                nextAttemptAt = agora,
            )
        )

        return pedido.id
    }
}

O método não tenta falar com Kafka. Isso reduz a latência da requisição e impede que uma indisponibilidade do broker segure locks da transação de negócio. O preço é consistência eventual: por alguns segundos, o pedido pode existir antes de seus consumidores reagirem. A interface e as regras de negócio precisam aceitar esse intervalo.

Para manter o código testável, injete Clock em vez de chamar Instant.now() em todos os pontos. E use migrations com Flyway ou Liquibase: depender de geração automática de schema em produção torna alterações no outbox difíceis de revisar.

Publicando com polling e SKIP LOCKED

A versão mais simples usa um job agendado. Em uma única instância, buscar os eventos pendentes parece suficiente. Com várias réplicas, porém, duas instâncias podem selecionar a mesma linha. PostgreSQL oferece FOR UPDATE SKIP LOCKED, que permite a cada worker reservar um lote diferente sem esperar por locks já adquiridos.

Uma abordagem clara usa NamedParameterJdbcTemplate para a consulta de claim:

@Repository
class OutboxClaimRepository(
    private val jdbc: NamedParameterJdbcTemplate,
) {
    fun claimBatch(limit: Int): List<ClaimedOutboxEvent> =
        jdbc.query(
            """
            SELECT id, aggregate_id, event_type, payload, attempts
              FROM outbox_event
             WHERE status = 'PENDING'
               AND next_attempt_at <= now()
             ORDER BY occurred_at
             FOR UPDATE SKIP LOCKED
             LIMIT :limit
            """.trimIndent(),
            mapOf("limit" to limit),
        ) { rs, _ ->
            ClaimedOutboxEvent(
                id = rs.getObject("id", UUID::class.java),
                aggregateId = rs.getString("aggregate_id"),
                eventType = rs.getString("event_type"),
                payload = rs.getString("payload"),
                attempts = rs.getInt("attempts"),
            )
        }
}

Há uma decisão importante aqui: não mantenha uma transação de banco aberta enquanto espera o Kafka por muito tempo. Um desenho comum é fazer claim rápido, alterando o estado para PROCESSING com um lease, confirmar a transação e publicar fora dela. Outro desenho publica um lote pequeno sob lock e aceita transações curtas. O primeiro escala melhor, mas precisa recuperar leases abandonados quando uma instância morre.

Uma máquina de estados mais robusta usa PENDING, PROCESSING, PUBLISHED e DEAD, além de locked_until e locked_by. Se locked_until expirar, outro worker pode recuperar o evento.

Enviando ao Kafka e marcando como publicado

Com Spring Kafka, espere a confirmação do envio antes de atualizar o outbox:

@Component
class OutboxPublisher(
    private val kafkaTemplate: KafkaTemplate<String, String>,
    private val outboxStateRepository: OutboxStateRepository,
) {
    fun publish(event: ClaimedOutboxEvent) {
        try {
            val record = ProducerRecord(
                topicFor(event.eventType),
                event.aggregateId,
                event.payload,
            ).apply {
                headers().add("event_id", event.id.toString().toByteArray())
                headers().add("event_type", event.eventType.toByteArray())
            }

            kafkaTemplate.send(record).get()
            outboxStateRepository.markPublished(event.id, Instant.now())
        } catch (exception: Exception) {
            outboxStateRepository.scheduleRetry(
                id = event.id,
                attempts = event.attempts + 1,
                error = exception.message?.take(2_000),
            )
        }
    }
}

get() deixa o exemplo explícito, mas limita throughput se usado um evento por vez. Em produção, você pode enviar o lote de forma assíncrona, aguardar os futures em conjunto e atualizar cada resultado. O princípio não muda: não marque como publicado antes do ACK do broker.

Mesmo assim, existe uma janela inevitável: Kafka confirma, a aplicação cai e o banco não recebe PUBLISHED. No próximo ciclo, o evento será enviado outra vez. É por isso que outbox significa entrega at-least-once, não exatamente uma vez para todo o sistema.

Retry, backoff e dead letter no próprio outbox

Retry imediato em loop pode derrubar o broker e o banco durante um incidente. Calcule o próximo horário com backoff exponencial e jitter:

fun nextAttempt(attempt: Int, now: Instant): Instant {
    val cappedAttempt = attempt.coerceAtMost(8)
    val baseSeconds = 2.0.pow(cappedAttempt.toDouble()).toLong()
    val jitter = ThreadLocalRandom.current().nextLong(0, 10)
    return now.plusSeconds((baseSeconds + jitter).coerceAtMost(900))
}

Depois de um limite — por exemplo, 12 tentativas — mova o evento para DEAD ou marque-o como tal. Não apague silenciosamente. Gere alerta com ID, tipo, quantidade de tentativas e erro sanitizado. O time precisa conseguir reprocessar o registro depois de corrigir schema, permissão ou configuração.

Separe falhas transitórias de permanentes:

  • timeout e indisponibilidade do Kafka: retry;
  • payload maior que o limite: correção ou dead letter;
  • tópico inexistente por configuração incorreta: alerta imediato;
  • serialização inválida: dead letter, pois repetir o mesmo payload não ajuda.

Consumidor idempotente

Todo consumidor deve assumir duplicidade. Um método prático é manter uma tabela de eventos processados com chave única:

CREATE TABLE processed_event (
    consumer_name VARCHAR(120) NOT NULL,
    event_id UUID NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (consumer_name, event_id)
);

No consumidor, registre o ID e aplique o efeito de negócio na mesma transação local:

@Transactional
fun consumir(event: PedidoCriadoV1) {
    val inserted = processedEventRepository.tryInsert(
        consumerName = "estoque-service",
        eventId = event.eventId,
    )

    if (!inserted) return

    reservaRepository.criarReserva(
        pedidoId = event.pedidoId,
    )
}

O tryInsert pode usar INSERT ... ON CONFLICT DO NOTHING. Se a reserva falhar, a transação inteira faz rollback, inclusive o registro de deduplicação. Quando Kafka reenviar a mensagem, o consumidor tenta novamente.

Outra possibilidade é tornar a própria operação naturalmente idempotente, usando pedido_id como chave única da reserva. Em muitos domínios, combinar as duas estratégias fornece uma proteção mais clara.

Ordem de eventos e particionamento

Kafka preserva ordem dentro de uma partição, não entre o tópico inteiro. Use aggregateId como chave para que PedidoCriado, PedidoPago e PedidoCancelado do mesmo pedido sigam para a mesma partição.

Isso não resolve todos os casos. Dois processos podem gerar eventos concorrentes para o mesmo agregado. Se ordem estrita for parte do contrato, inclua aggregateVersion e faça o consumidor detectar lacunas ou eventos antigos:

data class PedidoStatusAlteradoV1(
    val eventId: UUID,
    val pedidoId: UUID,
    val aggregateVersion: Long,
    val novoStatus: String,
    val occurredAt: Instant,
)

Não dependa apenas de timestamp: relógios podem divergir, e duas operações podem ter a mesma precisão temporal.

Polling ou CDC com Debezium?

Há duas formas comuns de retirar dados do outbox.

Polling publisher

A aplicação consulta a tabela periodicamente.

Vantagens:

  • implementação direta com Spring e PostgreSQL;
  • pouca infraestrutura adicional;
  • controle simples de retry e observabilidade;
  • bom para volume pequeno ou médio.

Desvantagens:

  • consultas recorrentes no banco;
  • latência depende do intervalo;
  • claim, lease e concorrência precisam ser implementados;
  • limpeza e índices exigem atenção.

Change Data Capture

Ferramentas como Debezium leem o log de transações do banco e transformam inserts do outbox em mensagens. O Event Router do Debezium existe especificamente para o padrão outbox.

Vantagens:

  • baixa latência sem polling constante;
  • alto throughput;
  • captura baseada no log transacional;
  • separação entre aplicação e publicação.

Desvantagens:

  • Kafka Connect/Debezium aumentam a superfície operacional;
  • schema, offsets e conectores precisam de monitoramento;
  • incidentes exigem conhecimento de CDC;
  • a aplicação ainda precisa produzir contratos versionados e consumidores idempotentes.

Comece com polling quando a equipe quer simplicidade e o volume cabe confortavelmente no banco. Migre para CDC quando latência, escala ou quantidade de serviços justificar a infraestrutura. Não adote Debezium apenas para evitar escrever um scheduler de poucas linhas sem avaliar o custo operacional.

Observabilidade que realmente ajuda

Um outbox pode estar “funcionando” e ainda entregar eventos com 40 minutos de atraso. Meça pelo menos:

  • quantidade de eventos PENDING;
  • idade do evento pendente mais antigo;
  • taxa de publicação por tipo;
  • latência entre occurred_at e published_at;
  • tentativas e falhas por causa;
  • quantidade de eventos DEAD;
  • duração e tamanho dos lotes;
  • consumer lag dos tópicos Kafka.

A métrica mais importante costuma ser a idade do pendente mais antigo. Uma fila com dez mil eventos novos pode estar saudável; uma fila com apenas um evento preso há seis horas pode indicar perda funcional grave.

Propague event_id, correlation_id e traceparent nos headers. Assim, ferramentas de OpenTelemetry com Kotlin, Spring e Ktor conseguem conectar a requisição original, o insert do outbox, a publicação e o processamento pelo consumidor.

Evite colocar o payload inteiro em logs. Além do custo, eventos podem conter dados pessoais. Registre IDs, tipo, versão, tentativa, duração e erro sanitizado.

Limpeza, retenção e particionamento da tabela

Uma tabela que nunca remove eventos publicados cresce indefinidamente. Defina retenção conforme auditoria e suporte — por exemplo, 7 a 30 dias para eventos publicados, mantendo falhas por mais tempo.

DELETE FROM outbox_event
 WHERE status = 'PUBLISHED'
   AND published_at < now() - interval '14 days';

Execute a limpeza em lotes pequenos para evitar locks longos e picos de WAL. Em volumes altos, considere particionamento por data e remoção de partições antigas. Monitore bloat e VACUUM no PostgreSQL.

Não use a tabela de outbox como histórico de negócio permanente. Se a empresa precisa de auditoria, event store ou ledger, modele essa responsabilidade separadamente. O outbox é uma fila transacional de entrega.

Testando com Testcontainers

Mocks não provam atomicidade, SKIP LOCKED, JSONB nem comportamento real do Kafka. Use testes de integração com PostgreSQL e Kafka em containers.

Cenários essenciais:

  1. sucesso: pedido e evento são gravados juntos;
  2. rollback: uma exceção depois do insert impede ambos os registros;
  3. broker indisponível: evento permanece pendente e recebe retry;
  4. duplicidade: o consumidor recebe o mesmo eventId duas vezes e aplica o efeito uma vez;
  5. concorrência: duas instâncias não processam o mesmo claim simultaneamente;
  6. processo morto: lease expirado volta a ser elegível;
  7. payload incompatível: evento vai para estado DEAD com alerta;
  8. ordem: eventos do mesmo agregado usam a mesma chave Kafka.

Exemplo de teste de rollback:

@SpringBootTest
@Testcontainers
class CriarPedidoServiceIT {
    @Test
    fun `rollback nao deixa pedido sem evento`() {
        assertFailsWith<RegraDeNegocioException> {
            service.executar(commandInvalidoAposPersistencia)
        }

        assertEquals(0, pedidoRepository.count())
        assertEquals(0, outboxRepository.count())
    }
}

Para validar infraestrutura de verdade, siga o guia de Testcontainers com Kotlin e PostgreSQL e evite substituir o banco por H2: sintaxe, locks e tipos do PostgreSQL fazem parte do comportamento que você precisa testar.

Erros comuns

Publicar no Kafka dentro do serviço e chamar isso de outbox

Se não existe registro durável na mesma transação da entidade, o dual write continua existindo.

Marcar como publicado antes do ACK

Uma falha depois da atualização perde o evento. Atualize o estado somente após a confirmação do broker.

Acreditar em exactly once global

Kafka possui recursos transacionais dentro de determinados limites, mas banco, produtor, consumidores e efeitos externos não viram magicamente uma única transação. Projete para duplicidade.

Não criar índice parcial

Sem um índice para pendentes, cada polling pode varrer milhões de linhas já publicadas.

Guardar classe interna como contrato

Renomear pacote ou propriedade quebra consumidores. Use evento explícito e versionado.

Retentar para sempre sem alerta

Um payload inválido ficará preso consumindo recursos. Tenha limite, estado DEAD, painel e procedimento de reprocessamento.

Ignorar consistência eventual no produto

Se a API responde “pedido criado”, serviços dependentes podem levar alguns segundos para reagir. Modele estados como “confirmação em processamento” quando necessário.

Checklist de produção

  • entidade e outbox são gravados na mesma transação local;
  • evento tem ID global, tipo, versão, agregado e timestamp;
  • payload não carrega dados sensíveis desnecessários;
  • aggregateId é usado como chave Kafka quando a ordem importa;
  • workers concorrentes usam claim seguro ou CDC;
  • confirmação do broker ocorre antes de PUBLISHED;
  • retry usa backoff, jitter e limite;
  • eventos mortos geram alerta e podem ser reprocessados;
  • consumidores são idempotentes;
  • há métricas de backlog, idade, falhas e latência;
  • limpeza preserva desempenho da tabela;
  • testes reais cobrem rollback, duplicidade e processo morto;
  • contratos têm estratégia de compatibilidade e versionamento.

Perguntas frequentes

Transactional Outbox garante exactly once?

Não de ponta a ponta. Ele garante que a intenção de publicar o evento seja persistida atomicamente com a alteração de negócio. A publicação pode ocorrer mais de uma vez, então consumidores devem ser idempotentes.

Preciso usar Kafka?

Não. O padrão funciona com RabbitMQ, serviços de fila e outros brokers. A ideia central é manter a escrita de negócio e o registro de saída na mesma transação local.

Posso usar Spring @TransactionalEventListener?

Ele ajuda a executar código após commit, mas sozinho não cria durabilidade. Se o processo cair depois do commit e antes do envio, o evento pode ser perdido. Use-o, quando fizer sentido, para acionar mecanismos que continuam apoiados por um registro persistente.

Polling no PostgreSQL escala?

Escala bem para muitos casos quando há índice parcial, lotes controlados, SKIP LOCKED, retenção e queries observadas. Para volume ou latência muito altos, CDC com Debezium pode ser mais adequado.

Devo apagar eventos publicados?

Sim, após uma janela de retenção compatível com suporte e auditoria. Faça limpeza em lotes ou por partições. Não deixe a tabela crescer sem limite.

Conclusão

Transactional Outbox com Kotlin, Spring Boot e Kafka resolve uma falha estrutural comum em backends distribuídos: tentar confirmar banco e broker como se fossem uma única operação. Ao gravar o evento junto com o pedido no PostgreSQL, a aplicação ganha um ponto durável para retry e deixa de depender de uma janela frágil entre commit e publicação.

A implementação madura vai além de criar uma tabela. Ela precisa de claim concorrente, confirmação do Kafka, backoff, dead letter, contratos versionados, consumidores idempotentes, métricas e retenção. Comece com polling se ele atende ao volume; considere Debezium quando CDC trouxer ganho operacional real. E trate duplicidade como parte normal do sistema, não como um caso impossível.

Para continuar, aprofunde-se em Kafka e RabbitMQ com Kotlin, resiliência com timeout, retry e circuit breaker e monólito modular com Kotlin e Spring. O outbox é útil tanto em microsserviços quanto em um monólito modular que precisa integrar domínios sem perder eventos.