---
title: "Transactional Outbox com Kotlin, Spring Boot e Kafka: eventos confiáveis em 2026"
url: "https://kotlin.dev.br/blog/transactional-outbox-kotlin-spring-kafka-2026/"
markdown_url: "https://kotlin.dev.br/blog/transactional-outbox-kotlin-spring-kafka-2026.MD"
description: "Implemente Transactional Outbox com Kotlin, Spring Boot, PostgreSQL e Kafka: transação única, polling, retries, idempotência, CDC e testes de integração."
date: "2026-09-08"
author: "Karina Melo"
---

# Transactional Outbox com Kotlin, Spring Boot e Kafka: eventos confiáveis em 2026

Implemente Transactional Outbox com Kotlin, Spring Boot, PostgreSQL e Kafka: transação única, polling, retries, idempotência, CDC e testes de integração.


**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](/blog/kotlin-kafka-rabbitmq-mensageria-2026/), [microsserviços com Kotlin](/guias/guia-kotlin-microservicos/) e [Testcontainers com PostgreSQL](/blog/testcontainers-kotlin-postgresql-testes-integracao-2026/).

## 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.

```kotlin
@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.

```text
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](https://docs.spring.io/spring-kafka/reference/) e do [Apache Kafka](https://kafka.apache.org/documentation/).

## Modelando a tabela de outbox

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

```sql
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:

```kotlin
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:

```kotlin
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:

```kotlin
@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:

```kotlin
@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:

```kotlin
@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:

```kotlin
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:

```sql
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:

```kotlin
@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:

```kotlin
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](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html) 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](/blog/opentelemetry-kotlin-spring-ktor-2026/) 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.

```sql
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:

```kotlin
@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](/blog/testcontainers-kotlin-postgresql-testes-integracao-2026/) 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](/blog/kotlin-kafka-rabbitmq-mensageria-2026/), [resiliência com timeout, retry e circuit breaker](/blog/ktor-client-resiliente-timeout-retry-circuit-breaker-2026/) e [monólito modular com Kotlin e Spring](/blog/monolito-modular-kotlin-spring-2026/). O outbox é útil tanto em microsserviços quanto em um monólito modular que precisa integrar domínios sem perder eventos.
