DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 28: a choreography saga — four services complete one order, no orchestrator, undone by a compensating event

I'm building OrderHub, an event-driven e-commerce backend, one service at a time. Days 25–27 built a producer and two independent reactions — inventory reserves stock, payment decides the charge — but they didn't add up to a transaction: nothing tied the two outcomes together or cleaned up when one failed. Day 28 chains them into a saga: a distributed transaction with no shared database and no central coordinator. Each service commits its own step and announces a fact; the next reacts to that fact. When something fails, there's no rollback — you roll forward with a compensating event. Here's how it's wired in Spring Boot and Kafka.

What a saga is — and why it can't roll back

An order now spans four services (order, inventory, payment, shipping), each with its own database and its own local transaction. There's no way to wrap all four in one ACID transaction — no two-phase commit across independent services. A saga splits the work into local steps: each either succeeds and moves the flow forward, or fails and triggers a compensating action that undoes what's already committed. Crucially, "undo" is not a rollback — inventory's reserve is already committed and durable — it's a new, opposite operation: release the stock you reserved.

FORWARD (happy path)                COMPENSATION (a step failed)
order placed   (order-service)      -> (nothing to undo; it's the trigger)
stock reserved (inventory)          release stock    (inventory)
payment approved (payment)          (a decline IS the failure)
order shipped  (shipping)           order cancelled  (order-service)
Enter fullscreen mode Exit fullscreen mode

order-service becomes a consumer too

Until now order-service only produced OrderPlaced. To run the saga it must also consume the two result facts: StockReserved on inventory-events and PaymentProcessed on payment-events. OrderSagaListener is those two @KafkaListeners, both on order-service's own consumer group (order-saga) — distinct from inventory's and payment's groups, so order-service gets its own independent copy of every result.

@KafkaListener(topics = "${orderhub.saga.stock-events-topic:inventory-events}",
    groupId = "${orderhub.saga.consumer-group-id:order-saga}",   // order-svc's OWN group
    containerFactory = "stockReservedListenerContainerFactory")
public void onStockReserved(StockReservedEvent e) { saga.onStockReserved(e); }
Enter fullscreen mode Exit fullscreen mode

SagaState correlates two events arriving in any order

A saga is a long-running transaction: something has to remember "I've seen the stock result but not the payment result yet." That's SagaState, one instance per order id in a ConcurrentHashMap (a DB row in production so it survives a restart). The two results arrive on different topics, in any order, possibly on different threads, so the state records each leg as it lands. The terminal flag is the idempotency guard and the most important field: the instant the saga decides, it latches terminal=true, so a redelivered event re-runs evaluate() but the decision happens exactly once.

class SagaState {
  private String stockOutcome;    // RESERVED | INSUFFICIENT_STOCK   (null = not seen)
  private String paymentOutcome;  // APPROVED | DECLINED             (null = not seen)
  private boolean terminal;       // latched once decided -> the idempotency guard
  boolean stockReserved()   { return "RESERVED".equals(stockOutcome); }
  boolean paymentApproved() { return "APPROVED".equals(paymentOutcome); }
}
Enter fullscreen mode Exit fullscreen mode

The decision: compensate early, ship only when both legs win

evaluate() is the whole policy, and it embodies two rules that matter in production. Failure fires early: a decline or a stock failure compensates immediately — there's no reason to wait for the other leg once the saga is doomed. Success waits for both: shipping requires stock RESERVED and payment APPROVED, so whichever result arrives second triggers the ship. Ordering is irrelevant, which is what makes it correct under Kafka's at-least-once, any-order delivery.

private void evaluate(SagaState s) {
  if (s.isTerminal()) return;                                   // decided once — idempotent
  if (s.paymentDeclined()) { compensate(s, PAYMENT_DECLINED);  return; }  // fail EARLY
  if (s.stockFailed())     { compensate(s, STOCK_UNAVAILABLE); return; }  // fail EARLY
  if (s.stockReserved() && s.paymentApproved()) { ship(s, s.amount()); }  // ship on BOTH
  // else: one leg still outstanding -> keep waiting
}
Enter fullscreen mode Exit fullscreen mode

Roll back with an event — and the compensation lands

There's no shared transaction to undo, so compensate() cancels the order, latches terminal, and emits OrderCancelled carrying the reason. order-service does not reach into inventory to free the stock — that would re-introduce the coupling events removed. It just states the fact. inventory-service's OrderCancelledListener reacts on its own, running the inverse of the reserve — atomically and idempotently, so stock goes back exactly once and a cancel for an order never reserved is a harmless no-op.

public void onOrderCancelled(OrderCancelledEvent event) {
  Optional<Reservation> released = ledger.releaseIfReserved(event.orderId());  // ATOMIC + IDEMPOTENT
  if (released.isEmpty()) return;                       // never reserved / already released -> no-op
  Reservation r = released.get();
  inventoryService.release(r.sku(), r.quantity());      // put the units back — the inverse
}
Enter fullscreen mode Exit fullscreen mode

Adding shipping-service, the new 7th module, is once again just adding a reactor — the reactor pom gains one line and nothing about the other six changes. It consumes PaymentProcessed, claims the order id in its ShipmentLedger (an atomic putIfAbsent for idempotency), and either schedules the shipment or emits the compensating cancel. Two @EmbeddedKafka tests prove ship, both compensation branches, and exactly-once — no Docker.

That's a choreography saga: the coordination logic is distributed across every service's listeners, emergent from who-reacts-to-what, with zero new coupling. Its trade-off is that no single place "owns" the flow — which is exactly the problem the next step, an orchestration saga, solves by adding a coordinator that holds the whole thing in one state machine.

The interactive walkthrough — run the saga, flip to insufficient stock or a declined card to see compensation, and redeliver to prove idempotency:
https://dev48v.infy.uk/orderhub.php

Repo: https://github.com/dev48v/order-hub-from-zero

Top comments (0)