saga-full — saga flow

One order touches five services — happy path and compensation.

Choreography, not orchestration: every service downstream of order-service reacts to Kafka events independently — nothing tells payment-service to compensate, it just happens to be subscribed to the same rejection event order-service reacts to. Three steps below — the outbox pattern, the topic map, and compensation itself — are zoomed into, since each is a real design decision worth understanding on its own.

Happy Path

Login (gRPC) POST /orders Create Order (PENDING) order-created-topic Charge Payment (APPROVED) payment-processed-topic Allocate Inventory restaurant-approved-topic Confirm Order (SUCCESS)

Rejection / Compensation

Allocate Inventory fails restaurant-rejected-topic Cancel Order (CANCELLED) + Compensate Payment (REFUNDED)

Zoomed In

Transactional Outbox

Domain write and event-to-publish, one transaction

Every service that emits a saga event writes an OutboxRecord in the same database transaction as its domain change — e.g. order-service creates a PENDING Order and its matching outbox row together, or neither happens. A separate @Scheduled poller (every 500ms, PESSIMISTIC_WRITE + SKIP LOCKED — safe across multiple instances of the same service) ships pending records to Kafka and deletes each one only after a confirmed send.

Kafka Topics

Four topics carry the whole saga

TopicPublisherConsumer(s)
order-created-topicorder-servicepayment-service
payment-processed-topicpayment-servicerestaurant-service
restaurant-approved-topicrestaurant-serviceorder-service
restaurant-rejected-topicrestaurant-serviceorder-service, payment-service
Compensation

Two consumers, one rejection event

restaurant-rejected-topic has two independent consumer groups: order-rejected-group (order-service, marks the order CANCELLED) and payment-compensation-group (payment-service, handleOrderCompensation — the actual compensating transaction, undoing a charge already committed because a later saga step failed). Neither consumer knows the other exists; both just react to the same topic. This is the concrete instance of the pattern this whole repo demonstrates.

i

Every consumer that mutates state guards against Kafka's at-least-once delivery with a cheap-check-before-load idempotency pattern (existsByOrderIdAndStatus, then findById, then a re-check after load) — a duplicate message must not double-charge or double-allocate inventory. Per-service detail (ports, schemas, key classes): Services Reference.