Event-driven order pipeline on Java 21 + Apache Kafka + Postgres, built to walk the V1 → V3 evolution: a deliberately-broken naive pipeline; through the V2.x correctness, throughput, multi-instance, and hardening arc one minor version at a time; into V3 where fulfillment becomes a saga state machine (payment → inventory → ship) with compensating actions on failure.
The V2.x correctness machinery — idempotency, transactional outbox, DLQ,
multi-instance coordination via SELECT FOR UPDATE SKIP LOCKED, notification
dedup — carries over unchanged in V3; the saga sits above it. Each
mechanism was driven into the codebase by a deterministic chaos test that
flipped its assertion at the version boundary; the diff on those tests is
the portfolio artifact. 11 documented failure modes (F1–F11), 11 chaos
tests, all green at HEAD.
| Spec | spec.md — RFC-2119 requirements, V1 → V3 evolution |
| Chaos report | docs/chaos-report.md — per-failure V1 baseline → fix history |
| Status | v3.0.0 released. All 11 documented failure modes (F1–F11) green in the chaos suite. |
flowchart LR
client([HTTP client]) -- POST /orders --> api[order-api<br/>Javalin :6080<br/>Idempotency-Key dedup]
api -- producer --> orders[(orders<br/>Kafka topic)]
orders -- consume --> ful[fulfillment-service<br/>byte[] deserialize]
ful -- parse FAIL --> dlq[(orders.dlq<br/>+ diagnostic headers)]
ful -- parse OK --> saga{{Saga orchestrator<br/>payment ▶ inventory ▶ ship<br/>compensations on failure<br/>state in sagas table}}
saga -- COMPLETED --> txOk[[DB tx:<br/>processed_orders<br/>+ outbox row]]
saga -- FAILED --> txFail[[DB tx:<br/>processed_orders<br/>only]]
txOk --> commit[commitSync offset]
txFail --> commit
relay[outbox-relay thread<br/>FOR UPDATE SKIP LOCKED] -- poll --> outbox[(outbox table)]
relay -- publish --> events[(order-events<br/>Kafka topic)]
relay -- mark published --> outbox
events --> notif[notification-service<br/>processed_notifications dedup]
notif -- log --> stdout([stdout])
pg[(Postgres)]
api -.- pg
ful -.- pg
relay -.- pg
notif -.- pg
| Service | Stack | Role |
|---|---|---|
order-api |
Java 21, Javalin, kafka-clients, JDBC | HTTP producer on :6080; optional Idempotency-Key header for HTTP-layer dedup |
fulfillment-service |
Java 21, kafka-clients, Postgres + saga orchestrator + outbox-relay thread | Saga drives payment → inventory → ship with compensations; per-batch commitSync after DB tx; parse failures → DLQ; outbox relay coordinates across instances via SKIP LOCKED |
notification-service |
Java 21, kafka-clients, Postgres | Consumer with manual commit; idempotent log emit via processed_notifications |
kafka |
apache/kafka:3.8.1 KRaft |
Single-node broker, 3 partitions per topic, auto.create.topics.enable=false |
postgres |
postgres:16-alpine |
Five Flyway-managed tables: processed_orders, outbox, idempotency_keys, processed_notifications, sagas |
The V1 → V3 story, with the failing chaos test that drove each fix and the version that landed it.
| # | V1 bug (or new failure mode) | Mechanism (version) | Test (inverted at version boundary) |
|---|---|---|---|
| F1 | A client retry on POST /orders produced 2 records on orders and 2 notifications. |
processed_orders lookup before any work; Idempotency-Key header for HTTP-layer dedup. |
F1_DuplicateOrdersTest — flipped from assertEquals(2, …) to assertEquals(1, …). |
| F2 | A consumer crash before the next auto-commit tick caused redelivery → second notification. | enable.auto.commit=false + commitSync after DB tx; redelivery hits the idempotency check and is skipped. |
F2_DuplicateNotificationsTest — flipped from 2 → 1. |
| F3 | Auto-commit advanced the offset before work completed → record lost on restart. | DB tx is the durability gate; commitSync runs only after COMMIT succeeds. |
F3_LostOrdersTest — flipped from assertEquals(0, …) to assertEquals(1, …). |
| F4 | Malformed JSON crashed the consumer thread; the partition stalled. | Read as byte[], parse manually; on parse failure, publish raw bytes to orders.dlq with diagnostic headers, commit the source offset, continue. |
F4_PoisonMessageTest — flipped to assert !consumer.crashed(), valid event = 1, DLQ size = 1, headers populated. |
| F5 | Single-thread × 50 ms / record → lag grew linearly under burst. | v2.1.0: poll() batches dispatched onto a virtual-thread executor; per-batch commitSync; bounded by --worker-pool-size semaphore (default 32); outbox relay moved to async batch publish. |
F5_ConsumerLagTest — flipped from assertTrue(lag > 200) to assertTrue(lag < 50); measured lag = 0 in 2 s window. |
| F6 | (new in v2.2.0 — didn't exist when fulfillment ran in a single JVM) Two relay instances poll the same WHERE published_at IS NULL rows → every event published twice. |
v2.2.0: SELECT FOR UPDATE SKIP LOCKED inside an explicit DB tx; the lock is held across the Kafka publish and the markPublishedBatch UPDATE so a second instance cannot lock-and-publish the same row. |
F6_MultiInstanceOutboxRaceTest — spawns two V2Stacks sharing broker + Postgres; asserts exactly N events for N orders. |
| F7 | Outbox-relay JVM crashes between successful Kafka publish and markPublishedBatch → on restart the row is republished. The idempotent producer dedupes within a session, not across crashes. Pre-v2.3.0 notification-service had no idempotency, so customers got two notifications. |
v2.3.0: processed_notifications table; atomic INSERT ... ON CONFLICT DO NOTHING before the log emit. Duplicate deliveries lose the conflict and are silently suppressed (duplicate notification suppressed log line). |
F7_NotificationDedupTest — publishes two identical OrderFulfilled records to order-events; asserts exactly one notification fires. |
| F8 | (new in v3.0.0 — only exists once fulfillment is multi-step) Payment step fails — the first step of the saga. | v3.0.0: saga transitions directly to FAILED with failure_step=payment; nothing to compensate. processed_orders row inserted (idempotency); no outbox row, no notification. |
F8_PaymentFailureNoCompensationTest |
| F9 | Inventory fails after payment success. Without compensation, the customer would be charged but never receive their order. | v3.0.0: orchestrator walks completed steps in reverse — payment.compensate() refunds the charge before saga reaches FAILED. |
F9_InventoryFailureCompensatesPaymentTest |
| F10 | Shipping fails after payment + inventory success. | v3.0.0: compensations run in reverse order: inventory.compensate() releases reserved stock, then payment.compensate() refunds. |
F10_ShippingFailureCompensatesAllTest |
| F11 | Consumer crashes mid-saga (between two completed steps and the next one). | v3.0.0: state-aware re-entry — every transition is committed to Postgres before the next step runs, so on redelivery the orchestrator picks up at the next pending step without re-executing completed work. | F11_SagaResumeAfterCrashTest |
Run the suite and watch the numbers come out:
$ make chaos
[INFO] Tests run: 1, in F1_DuplicateOrdersTest Time: 4.7s PASS
[INFO] Tests run: 1, in F2_DuplicateNotificationsTest Time: 5.6s PASS
[INFO] Tests run: 1, in F3_LostOrdersTest Time: 2.7s PASS
[INFO] Tests run: 1, in F4_PoisonMessageTest Time: 3.7s PASS
[INFO] Tests run: 1, in F5_ConsumerLagTest Time: 2.7s PASS
[INFO] Tests run: 1, in F6_MultiInstanceOutboxRaceTest Time: 5.8s PASS
[INFO] Tests run: 1, in F7_NotificationDedupTest Time: 3.1s PASS
[INFO] Tests run: 1, in F8_PaymentFailureNoCompensationTest Time: 0.1s PASS
[INFO] Tests run: 1, in F9_InventoryFailureCompensatesPay… Time: 3.7s PASS
[INFO] Tests run: 1, in F10_ShippingFailureCompensatesAllT… Time: 0.1s PASS
[INFO] Tests run: 1, in F11_SagaResumeAfterCrashTest Time: 0.1s PASS
=== chaos-test/target/lag-v2.1.txt ===
burst=500 window=2s lag=0For a deeper walkthrough of each test, the V1 baseline numbers, and the actual
DLQ payloads captured during a run, see docs/chaos-report.md.
Outbox vs Kafka native transactions. Kafka's initTransactions() API can
atomically commit producer sends and consumer offsets, but it does so against
Kafka, not against Postgres. As soon as the application has durable state
(processed_orders), there's nothing tying that state to the Kafka offset.
DB-backed outbox flips this: the DB transaction is the source of truth, the
relay republishes if it crashes mid-publish, and consumer-side idempotency
absorbs the duplicates. Kafka becomes at-least-once; correctness lives in
Postgres.
In-process relay vs sidecar. The outbox relay runs as a thread inside the
fulfillment-service JVM, sharing its connection pool. A separate process would
be cleaner architecturally but introduces deployment coupling, leader election,
and an extra failure mode. Multi-instance horizontal scaling landed in v2.2.0
without splitting the relay into a sidecar — SELECT FOR UPDATE SKIP LOCKED
on the outbox poll lets multiple in-process relays coordinate via the DB.
Two layers of idempotency. The Idempotency-Key header (HTTP layer) and
processed_orders lookup (consumer layer) overlap. Both stay because they
defend against different failure modes: HTTP retries that the broker never
sees vs Kafka redelivery that the order-api never sees. Removing either
opens a duplicate window.
Straight-to-DLQ on first parse failure. Production-grade systems retry N times before giving up. We don't — a JSON parse failure is structural, not transient, and infinite retries would still fail. Retry-before-DLQ was originally targeted for v2.3.0, then deferred (no transient-error path in the pipeline yet); V3.0.0 sagas introduce real retryable calls, so v3.1.0 will revisit retry topics layered on the saga steps where it actually buys something.
Fully-parallel batch processing, not per-key serial. v2.1.0 dispatches
every record in a poll() batch onto a virtual-thread executor with no
ordering constraint. This works because of a domain insight: in this
system, records with the same orderId are duplicates that the
processed_orders idempotency check absorbs, and records with different
orderId are independent. There is no per-key serialization requirement.
If the domain ever grows one ("first cancel then place"), the right move
is the Confluent Parallel Consumer
with KEY ordering — not rolling our own per-key locks.
Atomic claim, not check-then-insert. With v2.0.0's serial loop, the
fulfillment-service did SELECT 1 FROM processed_orders then INSERT.
That's a TOCTOU race the moment two workers run in parallel. v2.1.0
collapsed it into a single INSERT … ON CONFLICT DO NOTHING that returns
a row count: 1 = "we own this fulfillment, write the outbox row", 0 =
"someone else won, skip". The check IS the insert.
Multi-instance via SKIP LOCKED, not leader election. v2.2.0 lets the
fulfillment-service scale horizontally. The naive question is "who owns
the outbox?" — leader election with ZooKeeper or etcd is the textbook
answer. We took the simpler path: SELECT … FOR UPDATE SKIP LOCKED inside
an explicit DB transaction. Each unpublished row is locked by exactly one
instance's transaction; concurrent pollers SKIP LOCKED rows another
instance is holding and pick up disjoint work. Stateless, no coordination
service, no split-brain. The Postgres documentation calls this the
"competing consumers / work queue" pattern. The lock has to be held until
the Kafka publish AND markPublishedBatch have committed — releasing it
earlier reopens the duplicate-publish window the whole pattern exists to
close.
Idempotency at every consumer, not just at the producer. v2.3.0 closes
the last open V2.x correctness gap: the relay-crash duplicate-publish
window. The Kafka idempotent producer dedupes retries within a single
session; it cannot dedupe across a JVM crash that happens after a
successful publish but before the matching markPublishedBatch UPDATE
commits. The textbook fix is end-to-end exactly-once via Kafka
transactions, but that requires DB↔Kafka coordination we explicitly
rejected in Q9. The pragmatic fix is idempotent consumers —
notification-service claims each orderId in processed_notifications
with the same atomic INSERT-ON-CONFLICT pattern that fulfillment-service
uses for processed_orders. The duplicate, when it arrives, loses the
conflict and is silently suppressed.
Saga as a persisted state machine, not a transaction. V3.0.0 introduces the saga pattern. Three steps (payment → inventory → ship) each can fail; failures must compensate already-completed steps in reverse order. The orchestrator commits every state transition to Postgres before invoking the next step, which buys two important properties: (1) on JVM crash + redelivery, the orchestrator reads the saga's persisted state and picks up at the next pending step without re-executing completed ones (F11); (2) compensations are anchored in durable state, not in-memory bookkeeping. The trade-off is performance — each step adds a DB round-trip — and the assumption that compensations themselves succeed (compensation-failure handling is V3.1.0+ work). Real distributed sagas (separate services per step communicating over Kafka request/response) are a layer that fits cleanly on top of this state machine — V3.1.0+ when steps need to become real external calls.
make run # docker compose up --build
# brings up: kafka, postgres, order-api,
# notification-service, AND TWO
# fulfillment-service instances
curl -X POST http://localhost:6080/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"alice","items":[{"sku":"SKU-A","quantity":1,"unitPrice":9.99}]}'
# → {"orderId":"<uuid>"}Watch both instances pick up disjoint work:
docker logs ops-fulfillment-1 2>&1 | grep '"relay published count'
docker logs ops-fulfillment-2 2>&1 | grep '"relay published count'
# Each instance's tick count > 0; sum across both equals total outbox rows.
# Verify no duplicates:
docker exec ops-kafka /opt/kafka/bin/kafka-get-offsets.sh \
--bootstrap-server localhost:9092 --topic order-events \
| awk -F: '{sum+=$3} END {print sum}'
# == number of orders POSTedDemonstrate idempotency-key replay:
KEY=$(uuidgen)
curl -X POST http://localhost:6080/orders -H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{"customerId":"bob","items":[{"sku":"SKU-B","quantity":1,"unitPrice":4.50}]}'
# → {"orderId":"abc-123"}
# replay — same key, same payload, returns SAME orderId; no Kafka record produced
curl -X POST http://localhost:6080/orders -H "Idempotency-Key: $KEY" \
-H 'Content-Type: application/json' \
-d '{"customerId":"bob","items":[{"sku":"SKU-B","quantity":1,"unitPrice":4.50}]}'
# → {"orderId":"abc-123"}Demonstrate the DLQ:
echo 'not-valid-json' | docker exec -i ops-kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server localhost:9092 --topic orders
make consume-dlq # poison appears with x-dlq-* headers
docker compose ps # fulfillment-service still upmake run-local # starts kafka + postgres
mvn -DskipTests package
java -jar order-api/target/order-api.jar &
java -jar fulfillment-service/target/fulfillment-service.jar &
java -jar notification-service/target/notification-service.jar &make topics # list Kafka topics
make consume-orders # stream the orders topic
make consume-events # stream order-events
make consume-dlq # stream orders.dlq with headers
make psql # interactive PostgresUseful queries against the running Postgres:
-- fulfilled orders
SELECT order_id, customer_id, fulfilled_at FROM processed_orders ORDER BY fulfilled_at;
-- outbox progress (published_at NULL means relay hasn't sent yet)
SELECT id, topic, published_at IS NOT NULL AS published FROM outbox ORDER BY id;
-- idempotency keys (24 h TTL by convention; not auto-reaped)
SELECT key, order_id, created_at FROM idempotency_keys ORDER BY created_at DESC;
-- saga state (V3) — see what step each order reached and what failed
SELECT order_id, state, failure_step, failure_reason FROM sagas ORDER BY updated_at DESC;make build # mvn compile
make test # unit tests only — no broker, ~1s
make chaos # Testcontainers + Kafka + Postgres, runs F1-F11, ~30s
make package # shaded jars in each service's target/
make clean # mvn clean + docker compose down -vThe chaos suite is opt-in via the chaos Maven profile to keep the
unit-test loop fast.
order-processing-system/
├── spec.md RFC-2119 spec, V1 → V3 evolution
├── docs/chaos-report.md Per-failure V1 baseline → fix history
├── docker-compose.yml kafka + postgres + 4 service containers
├── Makefile
├── pom.xml parent (Java 21, dep management)
├── common/ shared DTOs, JSON serde, DB helpers, Flyway, TopicAdmin
│ └── src/main/resources/db/migration/ V1..V5 SQL migrations
├── order-api/ HTTP → Kafka, Idempotency-Key store
├── fulfillment-service/ saga orchestrator + outbox relay + DLQ
│ └── src/main/java/.../saga/ SagaState, SagaStep, *Step impls, SagaStore, SagaOrchestrator
├── notification-service/ consumer + log + processed_notifications dedup
└── chaos-test/ F1-F11 chaos tests (opt-in, profile=chaos)
Shipped:
| Version | Theme | Scope |
|---|---|---|
v1.0.0 ✅ |
Naive baseline | Three-service pipeline; auto-commit; in-memory state; no idempotency, no DLQ, no outbox. F1–F5 deliberately broken. |
v2.0.0 ✅ |
Correctness | F1–F4 fixed: processed_orders idempotency, transactional outbox, DLQ for poison, manual commitSync after DB tx |
v2.1.0 ✅ |
Throughput | F5 fixed — virtual-thread parallel batch + async outbox publish; lag 469 → 0 |
v2.2.0 ✅ |
Multi-instance | F6 fixed — SELECT FOR UPDATE SKIP LOCKED lets multiple fulfillment-service JVMs share the same outbox without duplicate publishes |
v2.3.0 ✅ |
Hardening | F7 fixed — processed_notifications table closes the relay-crash duplicate-publish window |
v3.0.0 ✅ |
Architectural shift | Saga pattern (payment → inventory → ship) with compensating actions; F8–F11 chaos coverage |
Open / future:
| Version | Theme | Scope |
|---|---|---|
v3.1.0 |
Saga hardening | Per-step bounded retries with backoff (the v2.4.0-deferred work, now meaningful since saga steps are real retryable calls); compensation-failure handling with retry + alerting; OrderFailed event topic for customer-facing failure notifications |
v3.2.0 |
Real distributed sagas | Split payment / inventory / shipping into separate services; orchestrator drives them via Kafka request/response; per-step idempotency keys (orderId + step.name) |
v3.3.0+ |
Choose-your-own | Schema registry + Avro/Protobuf; OpenTelemetry tracing across the saga; Prometheus metrics; saga-state Grafana dashboard |
- Kleppmann, Designing Data-Intensive Applications — chapters 11–12
- Confluent — Exactly Once Semantics Are Possible: Here's How Kafka Does It
- Chris Richardson — Pattern: Transactional Outbox, Pattern: Saga
- Microsoft — Asynchronous request-reply / Idempotency Key docs