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 -> CancelOrderExample: 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
| Choreography | Orchestration | |
|---|---|---|
| Who decides the next step? | Each service, by reacting to events | The orchestrator |
| Visibility of the flow | Spread across services | In one class + saga_instance table |
| Coupling | Services know events only | Orchestrator knows every participant |
| Timeouts ("payment did not reply in 5 min") | Hard | Easy: the orchestrator owns the timer |
| Best for | 2–4 simple steps | Longer 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)