Saga Orchestration

One Coordinator Drives the Flow

In orchestration a single component, the saga orchestrator (usually inside order-service), tells each participant what to do and waits for the reply. It stores the saga state, so you can always ask "where is this order?"

+------------------------+
                    |   OrderSagaOrchestrator |
                    |   state: saga_instance  |
                    +-----------+------------+
          1 ReserveStock        |        2 ChargePayment       3 CreateShipment
        +-----------------------+----------------------+-----------------------+
        v                                              v                       v
 inventory-service                              payment-service         shipping-service
        |  StockReserved / StockRejected               |  Charged / Declined   |
        +----------------------> replies <-------------+-----------------------+

 on failure the orchestrator sends compensations in REVERSE order:
   RefundPayment -> ReleaseStock -> CancelOrder

Example: An Orchestrated Saga with Compensations

Example

Each step has an action and a compensation. The orchestrator remembers completed steps on a stack; on failure it pops them and compensates in reverse order. Three runs: success, payment declined, and shipping failure (which must refund and release stock):

import java.util.*;

public class Main {
    public static void main(String[] args) {
        run(1001, 4000, true);    // happy path
        run(1002, 1000, true);    // wallet too low: payment fails
        run(1003, 4000, false);   // no courier: shipping fails
    }

    static void run(long orderId, int walletBalance, boolean courierAvailable) {
        System.out.println("--- Order " + orderId + " ---");
        Inventory inventory = new Inventory(10);
        Payment payment = new Payment(walletBalance);
        List<Step> steps = List.of(
            new Step("reserveStock", () -> inventory.reserve(2), () -> inventory.release(2)),
            new Step("chargePayment", () -> payment.charge(2998), () -> payment.refund(2998)),
            new Step("createShipment", () -> {
                if (!courierAvailable) throw new IllegalStateException("no courier for pincode 560001");
                System.out.println("  [shipping] shipment created");
            }, () -> {}));
        String result = new SagaOrchestrator().execute(steps);
        System.out.println("  order " + orderId + " -> " + result
                + " | stock=" + inventory.stock + ", wallet=" + payment.balance + "\n");
    }
}

record Step(String name, Runnable action, Runnable compensation) {}

class SagaOrchestrator {
    String execute(List<Step> steps) {
        Deque<Step> completed = new ArrayDeque<>();
        for (Step step : steps) {
            try {
                step.action().run();
                completed.push(step);
            } catch (RuntimeException e) {
                System.out.println("  FAILED " + step.name() + ": " + e.getMessage());
                while (!completed.isEmpty()) {
                    Step done = completed.pop();
                    System.out.println("  compensating " + done.name());
                    done.compensation().run();
                }
                return "CANCELLED";
            }
        }
        return "CONFIRMED";
    }
}

class Inventory {
    int stock;
    Inventory(int stock) { this.stock = stock; }
    void reserve(int qty) { stock -= qty; System.out.println("  [inventory] reserved " + qty + " (left " + stock + ")"); }
    void release(int qty) { stock += qty; System.out.println("  [inventory] released " + qty + " (left " + stock + ")"); }
}

class Payment {
    int balance;
    Payment(int balance) { this.balance = balance; }
    void charge(int amount) {
        if (balance < amount) throw new IllegalStateException("insufficient balance " + balance + " < " + amount);
        balance -= amount;
        System.out.println("  [payment] charged Rs." + amount);
    }
    void refund(int amount) { balance += amount; System.out.println("  [payment] refunded Rs." + amount); }
}

Output

--- Order 1001 ---
  [inventory] reserved 2 (left 8)
  [payment] charged Rs.2998
  [shipping] shipment created
  order 1001 -> CONFIRMED | stock=8, wallet=1002

--- Order 1002 ---
  [inventory] reserved 2 (left 8)
  FAILED chargePayment: insufficient balance 1000 < 2998
  compensating reserveStock
  [inventory] released 2 (left 10)
  order 1002 -> CANCELLED | stock=10, wallet=1000

--- Order 1003 ---
  [inventory] reserved 2 (left 8)
  [payment] charged Rs.2998
  FAILED createShipment: no courier for pincode 560001
  compensating chargePayment
  [payment] refunded Rs.2998
  compensating reserveStock
  [inventory] released 2 (left 10)
  order 1003 -> CANCELLED | stock=10, wallet=4000

A Persistent Orchestrator in Spring

In production the orchestrator must survive restarts, so its state lives in the order database. Commands go out over Kafka and replies come back on a reply topic:

@Entity
@Table(name = "saga_instance")
public class OrderSaga {
    @Id private Long orderId;
    @Enumerated(EnumType.STRING) private SagaStep step;     // RESERVING_STOCK, CHARGING, SHIPPING, COMPENSATING, DONE
    @Enumerated(EnumType.STRING) private SagaStatus status; // RUNNING, CONFIRMED, CANCELLED
    private String paymentId;
    @Version private int version;                            // optimistic lock: two replies cannot race
    // getters, setters
}

@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {

    private final OrderSagaRepository sagas;
    private final OrderRepository orders;
    private final CommandPublisher commands;   // writes to the outbox table (see next topic)

    @Transactional
    public void start(Order order) {
        sagas.save(OrderSaga.start(order.getId()));                       // step = RESERVING_STOCK
        commands.send("inventory-commands", new ReserveStock(order.getId(), order.items()));
    }

    @Transactional
    public void on(StockReserved reply) {
        OrderSaga saga = sagas.findById(reply.orderId()).orElseThrow();
        saga.moveTo(SagaStep.CHARGING);
        commands.send("payment-commands", new ChargePayment(reply.orderId(), reply.amount()));
    }

    @Transactional
    public void on(PaymentDeclined reply) {
        OrderSaga saga = sagas.findById(reply.orderId()).orElseThrow();
        saga.moveTo(SagaStep.COMPENSATING);
        commands.send("inventory-commands", new ReleaseStock(reply.orderId()));   // undo step 1
        orders.findById(reply.orderId()).orElseThrow().cancel("PAYMENT_DECLINED");
        saga.finish(SagaStatus.CANCELLED);
    }

    @Transactional
    public void on(ShipmentCreated reply) {
        sagas.findById(reply.orderId()).orElseThrow().finish(SagaStatus.CONFIRMED);
        orders.findById(reply.orderId()).orElseThrow().confirm();
    }
}

Tip: For long-running or complex flows, a workflow engine such as Temporal, Camunda or Axon stores saga state, retries and timers for you. Many teams start with a hand-written orchestrator like the one above.

Choreography vs Orchestration

ChoreographyOrchestration
Who decides the next step?Each service, by reacting to eventsThe orchestrator
Visibility of the flowSpread across servicesIn one class + saga_instance table
CouplingServices know events onlyOrchestrator knows every participant
Timeouts ("payment did not reply in 5 min")HardEasy: the orchestrator owns the timer
Best for2–4 simple stepsLonger flows, clear status, auditing

Note: Two important design rules for both styles: (1) use semantic states like PENDING so other requests know the order is not final yet; (2) make every step and compensation idempotent, because messages can be delivered twice.

Exercise

Exercise: Add a fourth step awardLoyaltyPoints (action: add 30 points; compensation: remove 30 points) to the orchestrated saga example, placed before createShipment. What is printed for order 1003 now?

Show answer
// in run(...)
int[] points = {0};
List<Step> steps = List.of(
    new Step("reserveStock", () -> inventory.reserve(2), () -> inventory.release(2)),
    new Step("chargePayment", () -> payment.charge(2998), () -> payment.refund(2998)),
    new Step("awardLoyaltyPoints", () -> points[0] += 30, () -> points[0] -= 30),
    new Step("createShipment", ...));

// order 1003 output:
//   [inventory] reserved 2 (left 8)
//   [payment] charged Rs.2998
//   FAILED createShipment: no courier for pincode 560001
//   compensating awardLoyaltyPoints
//   compensating chargePayment
//   [payment] refunded Rs.2998
//   compensating reserveStock
//   [inventory] released 2 (left 10)