Transactional Outbox Pattern

Architecture

Transactional Outbox Pattern

How placing an order becomes a confirmation email — across one Postgres transaction, a Debezium CDC hop, a Kafka topic, and two independently-scaled services.

HTTP CLIENT POST /orders rest-service 2 replicas · @Transactional Postgres StatefulSet SAME TX orders outbox Kafka Connect Debezium Postgres connector + Outbox Event Router SMT Kafka outbox.event.order 1 partition · RF 3 · 3 brokers email-service 2 replicas · 1 active consumer 5 req / 10s blocks, never drops confirmation email to customer writes order + outbox tails WAL (CDC) routes via SMT consumes sends (rate-limited) CDC hop — Debezium tails the WAL; no application code runs here trace context still crosses — outbox.trace_context column → traceparent header metrics traces inspect OBSERVABILITY PLANE Prometheus scrapes /actuator/prometheus + broker/connector metrics Kafka UI inspect topic & connector state Tempo OTLP traces from both services PromQL traces Grafana dashboards · trace explore · service graph
Same transaction CDC — tails WAL, no app code Kafka produce / consume Direct inspection Metrics (Prometheus) Traces (Tempo)
An order becomes an email: rest-service and Postgres share one transaction, so the order row and its outbox row either both land or neither does. Kafka Connect's Debezium relay turns the outbox row into a Kafka message — no application code ever calls Kafka directly. email-service consumes it behind a blocking rate limiter, so a burst of orders shows up as growing consumer lag, not lost mail. Below the line, Prometheus, Tempo, Grafana, and Kafka UI watch every hop without sitting in its path.

One transaction

rest-service writes the orders row and the outbox row in a single @Transactional call — never two systems to keep in sync.

CDC, not dual writes

Debezium tails Postgres's write-ahead log and republishes the outbox row — the only way an order ever reaches Kafka.

Blocks, never drops

The rate limiter (5 req/10s) blocks the consumer thread instead of rejecting. Lag grows under load; nothing is lost.

Trace survives the hop

The outbox row's trace_context column becomes a Kafka traceparent header, so one trace spans both services.