Programming on Mars
/
ArchitectureSpring BootKafkaJava

Transactional Outbox Pattern com Spring Boot e Kafka

Implemente o Transactional Outbox Pattern com Spring Boot e Kafka. PostgreSQL, SKIP LOCKED polling, exponential backoff e testes Testcontainers que provam as garantias na prática.

André Lucas

February 23, 2026

Transactional Outbox Pattern com Spring Boot e Kafka

Seu pedido foi salvo. Seu evento no Kafka, não.

@Transactional não resolve isso. Ele abre uma conexão JDBC e envolve suas escritas de banco num único bloco atômico — mas não sabe nada sobre Kafka. No momento em que você chama kafkaTemplate.send() dentro de um método @Transactional, você saiu do território ACID. Kafka é um sistema separado. Não tem conceito da sua transação e não tem como participar dela.

O modo de falha é silencioso. O commit do banco funciona. Então o Kafka dá timeout, ou o broker fica brevemente indisponível, ou a JVM crasha no milissegundo errado. O evento sumiu. Nenhuma exceção propaga de volta pro caller. Nenhum retry acontece. Serviços downstream nunca ficam sabendo que o pedido foi criado, e seus dados ficam silenciosamente inconsistentes. Esse é o dual-write problem.

Se você ainda não viu isso quebrar num demo rodando, comece pelo lab do Dual Write. Ele mostra exatamente onde a brecha se abre e termina com: "O Transactional Outbox Pattern resolve isso." Este lab é essa solução — uma implementação completa em Spring Boot com PostgreSQL 16, Redpanda (compatível com Kafka), exponential backoff retry, e três testes de integração com Testcontainers que provam as garantias contra infraestrutura real. O Mars Enterprise Kit PRO implementa o mesmo pattern no Spring Boot 4.0.

O que este lab cobre:

  • O schema outbox_events e por que cada coluna existe
  • Cinco classes anotadas: OrderService, OutboxWriter, OutboxProcessor, KafkaOutboxEventHandler e RetryPolicy
  • FOR UPDATE SKIP LOCKED e por que isso previne publicações duplicadas no Kafka quando múltiplas réplicas do scheduler rodam no Kubernetes
  • Três testes de integração com Testcontainers provando atomicidade, relay completo e resiliência a falha de broker contra PostgreSQL 16 e Kafka reais

Como o Transactional Outbox Pattern Funciona

A solução é mais simples do que parece. Em vez de escrever no Kafka dentro da transação de negócio, escreva uma linha de evento no banco de dados dentro da transação de negócio. Depois, um scheduler em background lê essas linhas e encaminha para o Kafka de forma independente.

É isso. O insight é que o banco de dados — não o Kafka — é a fonte de verdade do que precisa ser publicado. A tabela outbox_events é o buffer durável entre sua lógica de domínio e o message broker.

sequenceDiagram
    participant C as Client
    participant S as OrderService
    participant DB as PostgreSQL
    participant P as OutboxProcessor
    participant K as Kafka / Redpanda

    C->>S: POST /orders
    S->>DB: BEGIN TRANSACTION
    S->>DB: INSERT INTO orders
    S->>DB: INSERT INTO outbox_events (PENDING)
    S->>DB: COMMIT
    S-->>C: 201 Created

    loop a cada 5 s
        P->>DB: SELECT FOR UPDATE SKIP LOCKED
        P->>K: publish event
        alt Kafka disponível
            P->>DB: UPDATE status = COMPLETED
        else Kafka fora
            P->>DB: UPDATE retry_count++, next_retry_at
        end
    end

Como ambas as linhas são escritas em uma única transação de banco, elas fazem commit ou rollback juntas. Os três modos de falha que quebram a abordagem ingênua são todos tratados:

CenárioDual-write ingênuoTransactional Outbox
Kafka fora durante a requestEvento silenciosamente perdidoLinha fica PENDING, retentada na recuperação
Transação sofreu rollbackEvento já enviado — órfãoLinha do outbox também sofre rollback, sem phantom event
Múltiplos nós do schedulerEventos duplicados prováveisFOR UPDATE SKIP LOCKED previne entrega duplicada

O Schema

Flyway cria a tabela outbox_events em V2__create_outbox_events_table.sql. Se você é novo em migrations com Flyway, Spring Boot + Flyway — Database Migrations Step by Step cobre o setup em detalhe.

CREATE TABLE outbox_events (
    id              UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type      VARCHAR(255) NOT NULL,
    aggregate_id    VARCHAR(255) NOT NULL,
    aggregate_type  VARCHAR(255) NOT NULL,
    topic           VARCHAR(255) NOT NULL,
    payload         JSONB        NOT NULL,
    status          VARCHAR(20)  NOT NULL DEFAULT 'PENDING',
    retry_count     INT          NOT NULL DEFAULT 0,
    last_error      TEXT,
    next_retry_at   TIMESTAMPTZ,
    processed_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

-- Índice parcial: cobre apenas as linhas que o processor precisa escanear
CREATE INDEX idx_outbox_pending ON outbox_events (created_at)
    WHERE status = 'PENDING';

Três escolhas deliberadas neste schema:

Payload JSONB. Armazenar o payload do evento como JSON nativo significa que o PostgreSQL pode indexar ou consultar por valores de campos se necessário. Também valida a estrutura na escrita — JSON malformado falha imediatamente.

next_retry_at. Null em linhas novas. Incrementado por exponential backoff em cada tentativa de entrega falha. O scheduler ignora linhas onde next_retry_at > NOW(), então uma entrada falhando faz backoff sem bloquear o resto do batch.

Índice parcial em PENDING. A query WHERE status = 'PENDING' do scheduler só toca linhas não processadas. O índice só cobre elas — linhas completadas, que são a maioria ao longo do tempo, não inflam o índice. Numa tabela de alto volume com milhões de eventos históricos, isso mantém os query plans rápidos conforme a tabela cresce.


Walkthrough do Código

1. OrderService — a escrita atômica

OrderService.createOrder() é a peça central educacional de todo o pattern. Duas escritas, uma transação:

@Transactional
public Order createOrder(String customerId, BigDecimal amount) {
    // Passo 1: persistir a entidade de domínio
    Order order = orderRepository.save(Order.create(customerId, amount));

    // Passo 2: escrever a entrada do outbox — MESMA transação
    //
    // OutboxWriter NÃO tem @Transactional próprio. Ele participa da transação
    // aberta por ESTE método. Se este método fizer rollback, a linha do outbox
    // também faz rollback — nenhum phantom event chega ao Kafka.
    outboxWriter.write(
            "OrderCreated",
            order.getId().toString(),
            "Order",
            TOPIC,
            toJson(Map.of(
                    "orderId",    order.getId(),
                    "customerId", order.getCustomerId(),
                    "amount",     order.getAmount(),
                    "status",     order.getStatus()
            ))
    );

    return order;
}

Repare no que não está aqui: nenhum kafkaTemplate.send(), nenhum callback assíncrono, nenhum try/catch em volta de uma chamada ao broker. A única preocupação do método é persistir dados. Se um erro de validação mais adiante neste método causar um rollback, tanto a linha de orders quanto a de outbox_events desaparecem juntas. Nenhum evento é emitido para uma transação que nunca fez commit.

2. OutboxWriter — sem fronteira transacional própria

@Component
public class OutboxWriter {

    private final OutboxRepository outboxRepository;

    public OutboxWriter(OutboxRepository outboxRepository) {
        this.outboxRepository = outboxRepository;
    }

    public OutboxEntry write(
            String eventType,
            String aggregateId,
            String aggregateType,
            String topic,
            String payload) {
        OutboxEntry entry = OutboxEntry.create(eventType, aggregateId, aggregateType, topic, payload);
        return outboxRepository.save(entry);
    }
}

OutboxWriter não tem anotação @Transactional — deliberadamente. Ele participa de qualquer transação ativa no momento em que é chamado. Quando OrderService.createOrder() o chama, ele entra nessa transação. Esse é o mecanismo inteiro: o writer nunca deve abrir sua própria fronteira, porque isso tornaria a escrita do outbox independente da escrita de domínio — que é exatamente o problema que estamos resolvendo.

3. OutboxProcessorFOR UPDATE SKIP LOCKED

O scheduler roda a cada 5 segundos por padrão. Ele busca um batch de linhas pendentes e tenta publicar cada uma:

@Scheduled(fixedDelayString = "${outbox.poll-interval-ms:5000}")
@Transactional
public void processOutbox() {
    List<OutboxEntry> entries = outboxRepository.findPendingWithLock(batchSize);

    if (entries.isEmpty()) return;

    for (OutboxEntry entry : entries) {
        try {
            eventHandler.handle(entry);
            entry.markCompleted();
        } catch (Exception ex) {
            Instant nextRetry = retryPolicy.nextRetryAt(entry.getRetryCount());
            entry.scheduleRetry(nextRetry, ex.getMessage());
        }
        outboxRepository.save(entry);
    }
}

A query por trás de findPendingWithLock usa FOR UPDATE SKIP LOCKED do PostgreSQL:

@Query(value = """
        SELECT *
        FROM   outbox_events
        WHERE  status = 'PENDING'
          AND  (next_retry_at IS NULL OR next_retry_at <= NOW())
        ORDER  BY created_at ASC
        LIMIT  :limit
        FOR UPDATE SKIP LOCKED
        """, nativeQuery = true)
List<OutboxEntry> findPendingWithLock(@Param("limit") int limit);

FOR UPDATE trava cada linha selecionada. SKIP LOCKED pula qualquer linha que já está travada por outra sessão em vez de esperar. Num deploy Kubernetes com três réplicas rodando o scheduler, cada pod pega um batch diferente de linhas — sem espera, sem publicações duplicadas no Kafka. Sem SKIP LOCKED, os três pods entrariam em fila nas mesmas linhas e provavelmente publicariam duplicatas.

Cada mutação em processOutbox() faz commit na mesma transação do fetch: se o processo crashar no meio do batch, linhas em andamento voltam para PENDING e são retentadas no próximo poll.

4. KafkaOutboxEventHandler — sync-over-async

@Override
public void handle(OutboxEntry entry) throws Exception {
    kafkaTemplate
            .send(entry.getTopic(), entry.getAggregateId(), entry.getPayload())
            .get(10, TimeUnit.SECONDS); // bloqueia — transforma send assíncrono em exceção checked
}

KafkaTemplate.send() do Spring é assíncrono por padrão — retorna um CompletableFuture e segue em frente. Chamar .get(10, TimeUnit.SECONDS) bloqueia a thread até o broker confirmar ou o timeout disparar. Isso converte uma falha assíncrona silenciosa numa exceção lançada que o catch block do OutboxProcessor trata e converte num retry agendado. Sem o .get(), um timeout do Kafka passaria despercebido e a linha seria incorretamente marcada como COMPLETED.

A message key é o aggregateId — todos os eventos do mesmo pedido caem na mesma partição Kafka, preservando a ordenação por agregado.

KafkaOutboxEventHandler implementa a interface OutboxEventHandler, tornando-o substituível. Troque por um handler diferente para RabbitMQ, SNS ou qualquer outro transporte sem tocar no OutboxProcessor.

5. RetryPolicy — exponential backoff

public Instant nextRetryAt(int retryCount) {
    long delayMs = Math.min(
            (long) (initialDelayMs * Math.pow(multiplier, retryCount)),
            maxDelayMs
    );
    return Instant.now().plusMillis(delayMs);
}

Com os defaults (initialDelayMs=2000, multiplier=2.0, maxDelayMs=60000), a sequência de backoff é:

TentativaDelay
1a falha2 s
2a falha4 s
3a falha8 s
4a falha16 s
5a falha32 s
6a+ falha60 s (limite)

Quando o Kafka fica fora por um período prolongado, as entradas fazem backoff até um ciclo de retry de 60 segundos em vez de martelar o broker a cada poll de 5 segundos. Os três parâmetros são configuráveis no application.yml:

outbox:
  poll-interval-ms: 5000
  batch-size: 100
  retry:
    initial-delay-ms: 2000
    multiplier: 2.0
    max-delay-ms: 60000

Testes de Integração

OutboxIntegrationTest sobe PostgreSQL 16 e Kafka reais via Testcontainers e prova as três garantias contra a implementação real — sem mocks, sem brokers embutidos, sem overrides de spring test que mascaram comportamento incompatível com produção.

Os três testes cobrem:

  • Atomicidade: ambas as linhas de orders e outbox_events são sempre escritas juntas, ou nenhuma
  • Relay completo: o scheduler transiciona linhas PENDING para COMPLETED após o Kafka confirmar
  • Isolamento de broker: a transação de negócio completa independente da disponibilidade do Kafka
@SpringBootTest
@Testcontainers
class OutboxIntegrationTest {

    @Container
    static final PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"))
                    .withDatabaseName("outbox_demo")
                    .withUsername("outbox")
                    .withPassword("outbox");

    @Container
    static final KafkaContainer kafka =
            new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    @DynamicPropertySource
    static void overrideProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
        registry.add("outbox.poll-interval-ms", () -> "500"); // poll rápido nos testes
    }
    // ...
}

Teste 1: Escrita atômica

@Test
void createOrder_persistsOrderAndOutboxEntryInSameTransaction() {
    Order order = orderService.createOrder("customer-1", BigDecimal.valueOf(49.99));

    assertThat(order.getId()).isNotNull();

    List<OutboxEntry> entries = outboxRepository.findAll();
    assertThat(entries)
            .anyMatch(e ->
                    e.getAggregateId().equals(order.getId().toString()) &&
                    e.getEventType().equals("OrderCreated") &&
                    e.getStatus() == OutboxStatus.PENDING);
}

Após createOrder retornar, tanto a linha de orders quanto a de outbox_events existem no banco. A entrada do outbox está PENDING — o scheduler ainda não rodou. Este teste verifica que as duas escritas são verdadeiramente atômicas: se o banco persistiu o pedido, a linha do outbox está sempre ali junto.

Teste 2: Caminho completo do relay

@Test
void afterSchedulerRuns_outboxEntryIsCompletedAndKafkaReceivesMessage() throws Exception {
    Order order = orderService.createOrder("customer-2", BigDecimal.valueOf(99.99));

    // poll-interval-ms = 500 ms nos testes — espera até 10 s
    await()
            .atMost(Duration.ofSeconds(10))
            .untilAsserted(() -> {
                List<OutboxEntry> entries = outboxRepository.findAll();
                assertThat(entries)
                        .anyMatch(e ->
                                e.getAggregateId().equals(order.getId().toString()) &&
                                e.getStatus() == OutboxStatus.COMPLETED);
            });

    try (KafkaConsumer<String, String> consumer = createConsumer()) {
        consumer.subscribe(List.of("orders.events"));
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));

        assertThat(records.count()).isGreaterThan(0);

        boolean found = false;
        for (ConsumerRecord<String, String> record : records) {
            if (order.getId().toString().equals(record.key())) {
                assertThat(record.value()).contains(order.getId().toString());
                found = true;
                break;
            }
        }
        assertThat(found)
                .as("Expected to find Kafka message for order %s", order.getId())
                .isTrue();
    }
}

Este teste prova o caminho completo end-to-end: transação de negócio faz commit → scheduler faz poll → Kafka recebe mensagem com a key correta → linha transiciona para COMPLETED. Awaitility trata o gap assíncrono entre o commit e a execução do scheduler sem chamadas frágeis de Thread.sleep.

Teste 3: Dual-write problem resolvido

@Test
void whenKafkaIsDown_orderIsPersistedAndOutboxRemainsForRetry() {
    // createOrder() nunca toca o Kafka — apenas o processor em background.
    // Disponibilidade do Kafka é irrelevante para a transação de negócio.
    Order order = orderService.createOrder("customer-3", BigDecimal.valueOf(19.99));

    // Pedido deve ser persistido independente da disponibilidade do broker
    assertThat(order.getId()).isNotNull();

    // Entrada do outbox persistida — PENDING, aguardando retry
    List<OutboxEntry> entries = outboxRepository.findAll();
    assertThat(entries)
            .anyMatch(e ->
                    e.getAggregateId().equals(order.getId().toString()) &&
                    e.getStatus() == OutboxStatus.PENDING);
}

Este é o teste mais importante pedagogicamente. Ele demonstra a garantia central: a transação de negócio é completamente isolada da disponibilidade do Kafka. O pedido está seguro no banco. A linha do outbox é o buffer durável — ela fica PENDING até o broker se recuperar, momento em que o scheduler a pega e publica. Nenhum evento é silenciosamente descartado.


Tradeoffs e Alternativas

Latência de polling

O intervalo padrão de poll é 5 segundos. Para a maioria das cargas de trabalho — processamento de pedidos, filas de notificação, workflows assíncronos — um lag de 5 segundos no evento é aceitável. Se você precisa de latência sub-segundo, duas opções:

Reduzir o intervalo de poll. Configure outbox.poll-interval-ms para 500 ms ou menos. Isso aumenta a carga de polling no banco mas não requer infraestrutura adicional.

CDC com Debezium. Capture o write-ahead log da tabela outbox_events do PostgreSQL e faça stream das mudanças diretamente pro Kafka. O lag cai para milissegundos. O tradeoff: você adiciona um conector Debezium à sua infraestrutura e cria dependência do mecanismo de replication slot do PostgreSQL.

Entrega at-least-once

O outbox pattern entrega eventos pelo menos uma vez — nunca zero, mas potencialmente mais de uma vez. Se o scheduler publicar no Kafka com sucesso mas crashar antes de marcar a linha como COMPLETED, o próximo poll vai publicar o mesmo evento de novo. Consumers devem ser idempotentes. Use a combinação aggregateId + eventType como chave de deduplicação no lado do consumer.

Em produção, adicione uma verificação de max_retry_count dentro do OutboxProcessor: quando retry_count exceder seu threshold (ex: 10), transicione a linha para status DEAD e gere um alerta. Linhas DEAD representam eventos que requerem intervenção manual ou um processo de replay separado — deixar retry_count ilimitado significa que um consumer persistentemente quebrado envenena a fila do outbox indefinidamente.

Spring Modulith

Spring Modulith 1.x já vem com um outbox embutido baseado em ApplicationModuleListener e Spring Application Events. É uma opção com menos cerimônia se você já usa Spring Modulith e quer o outbox sem escrever o relay você mesmo. O tradeoff: você assume uma dependência de framework e abre mão do controle direto sobre a query de polling, tamanho do batch, lógica de retry e interface do handler. Esta implementação te dá tudo isso — ao custo de aproximadamente 200 linhas de código que agora são suas para ajustar.

Para o contexto mais amplo de por que consistência de dados em sistemas event-driven importa — event sourcing, CQRS, a diferença entre events e commands — veja Event-Driven Architecture: Events vs Commands Explained.

Serviço de relay dedicado

Para sistemas de alto volume, rodar o scheduler na mesma JVM da API cria acoplamento de backpressure: um Kafka broker lento pode saturar o pool de threads do scheduler e afetar a latência da API. Extrair o scheduler para uma aplicação Spring Boot separada — com seus próprios limites de recurso, cadência de deploy e política de scaling — desacopla as duas preocupações completamente.


Clone e Rode

Pré-requisitos: Docker, Java 21, Maven 3.9.

# Clone
git clone https://github.com/andrelucasti/outbox-pattern-spring-boot.git
cd outbox-pattern-spring-boot

# Suba PostgreSQL 16 + Redpanda
docker compose up -d

# Rode a aplicação (sobe na :8080)
./mvnw spring-boot:run

Crie um pedido:

curl -s -X POST http://localhost:8080/orders \
     -H "Content-Type: application/json" \
     -d '{"customerId": "mars-001", "amount": 99.99}' | jq .

Observe a linha do outbox transicionar de PENDING para COMPLETED em até 5 segundos:

psql -h localhost -U outbox -d outbox_demo \
  -c "SELECT id, event_type, status, retry_count, processed_at \
      FROM outbox_events ORDER BY created_at DESC LIMIT 5;"

Para ver a lógica de retry em ação, pare o Redpanda enquanto cria pedidos e observe retry_count e next_retry_at incrementando na tabela do outbox. Reinicie o Redpanda — o scheduler vai drenar o backlog automaticamente.

# Simule falha do broker
docker compose stop redpanda

curl -s -X POST http://localhost:8080/orders \
     -H "Content-Type: application/json" \
     -d '{"customerId": "retry-demo", "amount": 29.99}'

# Pedido persistido. Entrada do outbox está PENDING. Confira:
psql -h localhost -U outbox -d outbox_demo \
  -c "SELECT status, retry_count, last_error, next_retry_at FROM outbox_events;"

# Recupere o broker — eventos serão publicados automaticamente
docker compose start redpanda

Rode os testes de integração (Testcontainers gerencia seus próprios containers — sem setup manual):

./mvnw test

Clone, rode, quebre. O código fonte completo está em andrelucasti/outbox-pattern-spring-boot — schema, domain service, scheduler, retry policy, testes de integração, tudo rodando em menos de 5 minutos. Sem lock-in do Spring Modulith, sem infraestrutura de CDC, sem relay proprietário. Apenas uma tabela de banco, um scheduler em background e o posicionamento cuidadoso de uma única anotação @Transactional.

O próximo experimento: simule uma falha de broker com docker compose stop redpanda, observe retry_count incrementar na tabela do outbox, depois traga de volta e veja o backlog drenar automaticamente.

Tags

ArchitectureSpring BootKafkaJava
← Back to home