Pular para conteúdo

2. Transactional Outbox

O serviço order precisa fazer duas coisas quando um fã faz um pedido: guardar o pedido no PostgreSQL e anunciá-lo no Kafka. Dois sistemas diferentes, nenhuma transação compartilhada. Este passo mostra por que o código óbvio está errado e constrói a correção padrão, o transactional outbox.

O problema da escrita dupla

sequenceDiagram
    participant F as Fã
    participant O as order
    participant DB as PostgreSQL
    participant K as Kafka
    F->>O: POST /orders
    O->>DB: INSERT do pedido + COMMIT
    DB-->>O: ok
    Note over O: queda, deploy ou timeout do Kafka aqui
    O--xK: publica OrderPlaced (nunca acontece)
    O-->>F: 500 (mas o pedido existe!)

Qualquer ordem das duas escritas tem uma janela de falha:

Ordem das escritas Queda entre as escritas Resultado
Banco, depois Kafka pedido gravado, evento perdido nenhum e-mail, e nenhum outro serviço fica sabendo do pedido
Kafka, depois banco evento publicado, INSERT falha um pedido fantasma: e-mail de um pedido que não existe
Kafka dentro da transação do banco a publicação no Kafka dá certo, o commit falha o mesmo fantasma; o PostgreSQL não consegue desfazer o Kafka

O outbox tira o segundo sistema da requisição: o serviço grava o evento como uma linha de uma tabela outbox na mesma transação local do pedido. Ou as duas linhas existem, ou nenhuma. Um relay separado lê as linhas não publicadas e as publica, marcando-as como publicadas só depois que o Kafka confirma.

sequenceDiagram
    participant F as Fã
    participant O as order
    participant DB as PostgreSQL
    participant R as relay do outbox
    participant K as Kafka
    F->>O: POST /orders
    O->>DB: BEGIN; INSERT orders; INSERT outbox; COMMIT
    O-->>F: 201 Created
    loop a cada 200 ms
        R->>DB: SELECT ... WHERE published_at IS NULL FOR UPDATE SKIP LOCKED
        R->>K: envia (chave = orderId), espera acks=all
        K-->>R: ack
        R->>DB: UPDATE outbox SET published_at = now()
    end

Se o relay cair depois do ack do Kafka mas antes do UPDATE, a linha é publicada de novo na próxima execução: o outbox dá publicação at-least-once. Tudo bem, porque o consumidor do passo 3 é idempotente e cada evento carrega um eventId único.

Relay por polling ou CDC?

O relay aqui faz polling da tabela — simples, sem infraestrutura extra, algumas centenas de milissegundos de atraso. A alternativa é a captura de mudanças (change data capture, CDC): uma ferramenta como o Debezium lê o write-ahead log do PostgreSQL e publica cada inserção no outbox, com menos atraso e sem a carga do polling, ao custo de rodar e operar o Kafka Connect. Veja a página de padrões da aula de mensageria.

O serviço order

Um novo serviço Spring Boot em services/order/, na porta 8084, servindo tudo sob /orders. Ele usa JDBC puro (JdbcClient) em vez de JPA para que cada comando da transação fique visível.

services/order/
├── .dockerignore
├── Dockerfile
├── pom.xml
└── src/main/
    ├── java/boxoffice/order/
    │   ├── OrderApplication.java
    │   ├── OrderProperties.java
    │   ├── OrderController.java
    │   ├── OrderService.java
    │   ├── OrderRequest.java
    │   ├── OrderView.java
    │   ├── OrderEvent.java
    │   ├── NotificationClient.java
    │   └── outbox/
    │       ├── Outbox.java
    │       └── OutboxRelay.java
    └── resources/
        ├── application.yaml
        └── db/migration/V1__create_orders.sql

Arquivos de build

services/order/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>order</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-restclient</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
services/order/Dockerfile
# syntax=docker/dockerfile:1
# same Dockerfile as catalog (lab 1); only the port changes
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8084
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/order/.dockerignore
target/
.idea/
.vscode/
*.iml
services/order/src/main/resources/application.yaml
server:
  port: 8084

spring:
  application:
    name: order
  mvc:
    problemdetails:
      enabled: true
  datasource:
    url: jdbc:postgresql://${DB_HOST:db}:${DB_PORT:5432}/${DB_NAME:boxoffice}?currentSchema=orders
    username: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
  flyway:
    schemas: orders
  data:
    redis:
      host: ${REDIS_HOST:redis}
      port: 6379
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:kafka:9092}
    producer:
      acks: all
      properties:
        enable.idempotence: true
        linger.ms: 5
        max.block.ms: 5000     # Kafka down: fail the relay run in 5 s (default 60 s) and retry

boxoffice:
  order:
    notify-mode: ${NOTIFY_MODE:outbox}
    notification-url: ${NOTIFICATION_URL:http://notification:8086}
    topic: order-events
    relay-interval-ms: 200
  waiting-room:
    admit-per-second: ${ADMIT_PER_SECOND:20}
    admission-ttl: 5m

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      probes:
        enabled: true        # /actuator/health/liveness and /readiness

O produtor usa o StringSerializer padrão do Spring Boot para chave e valor: o outbox já guarda o evento como texto JSON, então o relay o publica byte a byte. acks: all mais enable.idempotence: true fazem o broker deduplicar as próprias retentativas do produtor e manter a ordem delas dentro de uma partição.

Esquema

services/order/src/main/resources/db/migration/V1__create_orders.sql
CREATE TABLE orders (
    id             UUID          PRIMARY KEY,
    show_id        VARCHAR(64)   NOT NULL,
    hold_id        VARCHAR(64)   NOT NULL UNIQUE,
    customer_email VARCHAR(320)  NOT NULL,
    amount         NUMERIC(10,2) NOT NULL,
    status         VARCHAR(20)   NOT NULL,
    created_at     TIMESTAMPTZ   NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id             UUID         PRIMARY KEY,
    aggregate_type VARCHAR(50)  NOT NULL,
    aggregate_id   UUID         NOT NULL,
    event_type     VARCHAR(50)  NOT NULL,
    payload        JSONB        NOT NULL,
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT clock_timestamp(),
    published_at   TIMESTAMPTZ
);

-- the relay only ever looks at unpublished rows: keep that index tiny
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;

hold_id é UNIQUE: uma reserva de assentos (do lab 6) vira no máximo um pedido. Essa restrição é o que torna o POST /orders idempotente — um fã que clica duas vezes, ou um gateway que tenta de novo depois de um timeout, recebe o mesmo pedido de volta em vez de um segundo.

Records de domínio

services/order/src/main/java/boxoffice/order/OrderRequest.java
package boxoffice.order;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;

public record OrderRequest(
        @NotBlank String showId,
        @NotBlank String holdId,
        @NotBlank @Email String customerEmail,
        @NotNull @Positive BigDecimal amount) {
}
services/order/src/main/java/boxoffice/order/OrderView.java
package boxoffice.order;

import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;

public record OrderView(
        UUID id,
        String showId,
        String holdId,
        String customerEmail,
        BigDecimal amount,
        String status,
        OffsetDateTime createdAt) {
}
services/order/src/main/java/boxoffice/order/OrderEvent.java
package boxoffice.order;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

/** The fact published on order-events. eventId is unique per event: consumers dedupe by it. */
public record OrderEvent(
        UUID eventId,
        String eventType,
        UUID orderId,
        String showId,
        String holdId,
        String customerEmail,
        BigDecimal amount,
        Instant occurredAt) {

    public static OrderEvent placed(OrderView order) {
        return new OrderEvent(UUID.randomUUID(), "OrderPlaced", order.id(), order.showId(),
                order.holdId(), order.customerEmail(), order.amount(), Instant.now());
    }
}
services/order/src/main/java/boxoffice/order/OrderProperties.java
package boxoffice.order;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

@ConfigurationProperties(prefix = "boxoffice.order")
public record OrderProperties(
        @DefaultValue("outbox") NotifyMode notifyMode,
        @DefaultValue("http://notification:8086") String notificationUrl,
        @DefaultValue("order-events") String topic,
        @DefaultValue("200") long relayIntervalMs) {

    public enum NotifyMode { SYNC, OUTBOX }
}
services/order/src/main/java/boxoffice/order/OrderApplication.java
package boxoffice.order;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan
public class OrderApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

A transação: pedido + outbox

A chave NOTIFY_MODE mantém a versão "antes" no código para que você possa medir as duas no passo 5: sync chama notification por HTTP dentro da requisição, outbox grava a linha do evento.

services/order/src/main/java/boxoffice/order/OrderService.java
package boxoffice.order;

import boxoffice.order.OrderProperties.NotifyMode;
import boxoffice.order.outbox.Outbox;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    public record PlaceResult(OrderView order, boolean created) {
    }

    private static final String SELECT = """
            SELECT id, show_id, hold_id, customer_email, amount, status, created_at
            FROM orders
            """;

    private final JdbcClient jdbc;
    private final Outbox outbox;
    private final NotificationClient notifications;
    private final OrderProperties props;

    public OrderService(JdbcClient jdbc, Outbox outbox, NotificationClient notifications,
                        OrderProperties props) {
        this.jdbc = jdbc;
        this.outbox = outbox;
        this.notifications = notifications;
        this.props = props;
    }

    @Transactional
    public PlaceResult place(OrderRequest req) {
        int inserted = jdbc.sql("""
                INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
                VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
                ON CONFLICT (hold_id) DO NOTHING
                """)
                .params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
                .update();

        OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
                .param(req.holdId())
                .query(OrderView.class)
                .single();

        if (inserted == 0) {
            // same hold already ordered: idempotent replay, no second event
            return new PlaceResult(order, false);
        }

        OrderEvent event = OrderEvent.placed(order);
        if (props.notifyMode() == NotifyMode.SYNC) {
            notifications.send(event); // BEFORE: the fan waits for the e-mail
        } else {
            outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
        }
        return new PlaceResult(order, true);
    }

    public Optional<OrderView> find(UUID id) {
        return jdbc.sql(SELECT + " WHERE id = ?").param(id).query(OrderView.class).optional();
    }
}
services/order/src/main/java/boxoffice/order/NotificationClient.java
package boxoffice.order;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

/** Only used in NOTIFY_MODE=sync: the "before" architecture. */
@Component
public class NotificationClient {

    private final RestClient http;

    public NotificationClient(RestClient.Builder builder, OrderProperties props) {
        this.http = builder.baseUrl(props.notificationUrl()).build();
    }

    public void send(OrderEvent event) {
        http.post().uri("/notifications").body(event).retrieve().toBodilessEntity();
    }
}
services/order/src/main/java/boxoffice/order/OrderController.java (step 2 version, replaced in step 4)
package boxoffice.order;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orders;

    public OrderController(OrderService orders) {
        this.orders = orders;
    }

    @PostMapping
    public ResponseEntity<OrderView> place(@Valid @RequestBody OrderRequest req) {
        OrderService.PlaceResult result = orders.place(req);
        if (!result.created()) {
            return ResponseEntity.ok(result.order());
        }
        return ResponseEntity.created(URI.create("/orders/" + result.order().id())).body(result.order());
    }

    @GetMapping("/{id}")
    public OrderView get(@PathVariable UUID id) {
        return orders.find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}

ON CONFLICT (hold_id) DO NOTHING devolve 0 linhas quando a reserva já virou pedido; o serviço então devolve o pedido existente com 200 OK e não grava evento. Sob concorrência, o PostgreSQL faz o segundo INSERT esperar a primeira transação e então pular — sem corrida, sem duplicata.

O outbox e seu relay

services/order/src/main/java/boxoffice/order/outbox/Outbox.java
package boxoffice.order.outbox;

import tools.jackson.databind.ObjectMapper;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class Outbox {

    private final JdbcClient jdbc;
    private final ObjectMapper mapper;

    public Outbox(JdbcClient jdbc, ObjectMapper mapper) {
        this.jdbc = jdbc;
        this.mapper = mapper;
    }

    /** MANDATORY: fails fast if someone calls it outside the business transaction. */
    @Transactional(propagation = Propagation.MANDATORY)
    public void append(UUID eventId, String aggregateType, UUID aggregateId, String eventType, Object payload) {
        String json = mapper.writeValueAsString(payload); // Jackson 3: exceptions are unchecked
        jdbc.sql("""
                INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
                VALUES (?, ?, ?, ?, ?::jsonb)
                """)
                .params(eventId, aggregateType, aggregateId, eventType, json)
                .update();
    }
}
services/order/src/main/java/boxoffice/order/outbox/OutboxRelay.java
package boxoffice.order.outbox;

import boxoffice.order.OrderProperties;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class OutboxRelay {

    private static final Logger log = LoggerFactory.getLogger(OutboxRelay.class);

    record Pending(UUID id, UUID aggregateId, String eventType, String payload) {
    }

    private final JdbcClient jdbc;
    private final JdbcTemplate jdbcTemplate;
    private final KafkaTemplate<String, String> kafka;
    private final String topic;

    public OutboxRelay(JdbcClient jdbc, JdbcTemplate jdbcTemplate,
                       KafkaTemplate<String, String> kafka, OrderProperties props) {
        this.jdbc = jdbc;
        this.jdbcTemplate = jdbcTemplate;
        this.kafka = kafka;
        this.topic = props.topic();
    }

    @Scheduled(fixedDelayString = "${boxoffice.order.relay-interval-ms:200}")
    @Transactional
    public void relay() throws Exception {
        // SKIP LOCKED: several order replicas can relay in parallel without taking the same rows
        List<Pending> batch = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload::text AS payload
                FROM outbox
                WHERE published_at IS NULL
                ORDER BY created_at
                LIMIT 100
                FOR UPDATE SKIP LOCKED
                """)
                .query(Pending.class)
                .list();
        if (batch.isEmpty()) {
            return;
        }

        List<CompletableFuture<SendResult<String, String>>> acks = new ArrayList<>();
        for (Pending p : batch) {
            ProducerRecord<String, String> record =
                    new ProducerRecord<>(topic, p.aggregateId().toString(), p.payload());
            record.headers().add("eventId", p.id().toString().getBytes(StandardCharsets.UTF_8));
            record.headers().add("eventType", p.eventType().getBytes(StandardCharsets.UTF_8));
            acks.add(kafka.send(record));
        }
        // wait for every ack; on failure the transaction rolls back and the rows are retried
        CompletableFuture.allOf(acks.toArray(new CompletableFuture<?>[0])).get(10, TimeUnit.SECONDS);

        jdbcTemplate.batchUpdate("UPDATE outbox SET published_at = clock_timestamp() WHERE id = ?",
                batch.stream().map(p -> new Object[] {p.id()}).toList());
        log.info("relayed {} event(s) to {}", batch.size(), topic);
    }

    @Scheduled(cron = "0 0 * * * *")
    @Transactional
    public void purge() {
        int n = jdbcTemplate.update(
                "DELETE FROM outbox WHERE published_at < now() - interval '1 day'");
        log.info("purged {} published outbox row(s)", n);
    }
}

Leia o relay com calma; cada linha responde a uma pergunta sobre falhas:

Linha Falha que ela trata
FOR UPDATE SKIP LOCKED duas réplicas de order fazendo relay ao mesmo tempo: cada uma trava linhas diferentes, nenhuma espera
ORDER BY created_at + chave = orderId eventos do mesmo pedido chegam à mesma partição na ordem do commit
allOf(...).get(10, SECONDS) Kafka fora ou lento: a transação é desfeita, as linhas continuam não publicadas, a próxima execução tenta de novo
UPDATE ... published_at depois dos acks uma queda antes dele republica o lote (at-least-once) — nunca o perde
purge() a tabela não cresce para sempre; linhas publicadas ficam um dia para depuração

Adicione order ao Compose e teste

docker-compose.yaml (excerpt)
  order:
    build: ./services/order
    environment:
      DB_HOST: db
      DB_PORT: 5432
      DB_NAME: ${DB_NAME:-boxoffice}
      DB_USER: ${DB_USER:-boxoffice}
      DB_PASSWORD: ${DB_PASSWORD:-boxoffice}
      REDIS_HOST: redis
      KAFKA_BOOTSTRAP_SERVERS: kafka:9092
      NOTIFICATION_URL: http://notification:8086
      NOTIFY_MODE: ${NOTIFY_MODE:-outbox}            # outbox (after) | sync (before)
      ADMIT_PER_SECOND: ${ADMIT_PER_SECOND:-20}      # waiting-room admission rate
    ports:
      - "8084:8084"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
      kafka-init:
        condition: service_completed_successfully
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 768M
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8084/actuator/health/readiness || exit 1"]
      interval: 10s
      timeout: 3s
      start_period: 40s
      retries: 3
docker compose up -d --build --wait ordercurl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \ -d '{"showId":"show-42","holdId":"hold-0001","customerEmail":"ana@example.com","amount":350.00}'{"id":"28cb9305-e1ce-4b49-aba7-665bb1b244b5","showId":"show-42","holdId":"hold-0001","customerEmail":"ana@example.com","amount":350.00,"status":"PENDING_PAYMENT","createdAt":"2026-10-18T17:55:45.063089Z"}docker compose logs order | grep relayedorder-1 | 2026-10-18T17:55:48.594Z INFO 1 --- [order] [ scheduling-1] boxoffice.order.outbox.OutboxRelay : relayed 1 event(s) to order-events

Abra o Kafka UI (http://localhost:8089) → Topics → order-events → Messages: o evento está lá, com chave = id do pedido e os headers eventId e eventType.

Agora prove as duas garantias:

  1. Requisição idempotente. Envie o mesmo curl de novo: a resposta é 200 (não 201) com o mesmo id, e nenhuma mensagem nova aparece no tópico.
  2. Nenhum evento perdido. Pare o Kafka, faça um pedido, suba o Kafka de novo:

    docker compose stop kafka
    curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \
      -d '{"showId":"show-42","holdId":"hold-0002","customerEmail":"bia@example.com","amount":350.00}'
    docker compose exec db psql -U boxoffice -d boxoffice \
      -c "SELECT event_type, created_at, published_at FROM orders.outbox ORDER BY created_at DESC LIMIT 2;"
    docker compose start kafka
    

    Você deve ver 201 no pedido e, na primeira consulta, a nova linha com published_at vazio. Espere o docker compose ps kafka voltar a healthy (de 10 s a um minuto) e rode a consulta de novo: published_at agora está preenchido. Em uma execução de teste:

     event_type  |          created_at           |         published_at
    -------------+-------------------------------+-------------------------------
     OrderPlaced | 2026-10-18 18:02:40.865762+00 | 2026-10-18 18:03:51.614915+00
     OrderPlaced | 2026-10-18 17:57:39.391694+00 | 2026-10-18 17:57:39.691802+00
    

    O pedido foi aceito com o Kafka fora e publicado 71 s depois, quando o Kafka voltou; o fã nunca viu a queda. Enquanto o Kafka fica fora por mais de 5 s, docker compose logs order mostra ERROR … Unexpected error occurred in scheduled task a cada poucos segundos: cada execução do relay falha depois de max.block.ms (5 s) e a próxima tenta de novo — esperado.

Armadilhas

  • Publicar dentro da requisição HTTP "depois do commit" (por exemplo, com @TransactionalEventListener) ainda perde o evento se o processo morrer entre o commit e o envio. Só uma linha durável no outbox sobrevive a uma queda.
  • Ordem entre réplicas. Com vários relays, dois eventos do mesmo pedido poderiam ser publicados por réplicas diferentes fora de ordem. Aqui cada pedido tem um evento por vez; se você emitir vários por pedido, faça o relay por agregado (por exemplo, ORDER BY aggregate_id, created_at) ou use CDC.
  • O outbox não é uma fila para o mundo. Ele é privado de order; os outros serviços leem o tópico do Kafka, nunca a tabela.

Próximo: Consumidor idempotente e DLT