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
<?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>
# 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"]
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
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
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) {
}
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) {
}
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());
}
}
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 }
}
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.
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();
}
}
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();
}
}
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
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();
}
}
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
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
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:
- Requisição idempotente. Envie o mesmo
curlde novo: a resposta é200(não201) com o mesmoid, e nenhuma mensagem nova aparece no tópico. -
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 kafkaVocê deve ver
201no pedido e, na primeira consulta, a nova linha compublished_atvazio. Espere odocker compose ps kafkavoltar ahealthy(de 10 s a um minuto) e rode a consulta de novo:published_atagora 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+00O 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 ordermostraERROR … Unexpected error occurred in scheduled taska cada poucos segundos: cada execução do relay falha depois demax.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.