Saga Choreography
Services React to Events
In choreography there is no central controller. Each service listens for events, does its local transaction and publishes a new event. The saga emerges from these reactions.
order-service inventory-service payment-service
| | |
|-- OrderCreated ------->| |
| |-- StockReserved ------>|
| | |-- PaymentCompleted --+
|<-----------------------------------------------------------------------+
CONFIRMED
failure path:
| | |-- PaymentFailed ---+
| |<-------------------------------------------+
| | release stock
|<-- StockReleased ------|
CANCELLEDExample: Choreography with an In-Memory Event Bus
Example
Here a tiny event bus stands in for Kafka (events are delivered in order, like one Kafka partition). Order 1002 cannot be paid, so payment-service publishes PaymentFailed and inventory-service compensates by releasing the stock:
import java.util.*;
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
placeOrder(1001, 5000);
placeOrder(1002, 1000);
}
static void placeOrder(long orderId, int wallet) {
System.out.println("== Order " + orderId + " (wallet Rs." + wallet + ") ==");
EventBus bus = new EventBus();
int[] stock = {10};
String[] status = {"PENDING"};
// inventory-service
bus.on("OrderCreated", e -> { stock[0] -= 2; bus.publish(new Event("StockReserved", e.orderId(), e.amount())); });
bus.on("PaymentFailed", e -> { stock[0] += 2; bus.publish(new Event("StockReleased", e.orderId(), e.amount())); });
// payment-service
bus.on("StockReserved", e -> bus.publish(new Event(
wallet >= e.amount() ? "PaymentCompleted" : "PaymentFailed", e.orderId(), e.amount())));
// order-service
bus.on("PaymentCompleted", e -> status[0] = "CONFIRMED");
bus.on("StockReleased", e -> status[0] = "CANCELLED");
bus.publish(new Event("OrderCreated", orderId, 2998));
bus.deliverAll();
System.out.println(" result: order " + status[0] + ", stock left " + stock[0]);
System.out.println();
}
}
record Event(String type, long orderId, int amount) {}
class EventBus {
private final Map<String, List<Consumer<Event>>> handlers = new HashMap<>();
private final Deque<Event> queue = new ArrayDeque<>();
void on(String type, Consumer<Event> handler) {
handlers.computeIfAbsent(type, t -> new ArrayList<>()).add(handler);
}
void publish(Event e) {
System.out.println(" event: " + e.type());
queue.add(e);
}
void deliverAll() {
while (!queue.isEmpty()) {
Event e = queue.poll();
handlers.getOrDefault(e.type(), List.of()).forEach(h -> h.accept(e));
}
}
}Output
== Order 1001 (wallet Rs.5000) == event: OrderCreated event: StockReserved event: PaymentCompleted result: order CONFIRMED, stock left 8 == Order 1002 (wallet Rs.1000) == event: OrderCreated event: StockReserved event: PaymentFailed event: StockReleased result: order CANCELLED, stock left 10
The Same Step with Spring Kafka
// inventory-service
@Component
@RequiredArgsConstructor
public class InventoryEventHandler {
private final InventoryService inventoryService;
private final KafkaTemplate<String, Object> kafka;
@KafkaListener(topics = "order-events", groupId = "inventory-service")
public void onOrderCreated(OrderCreated event) {
try {
inventoryService.reserve(event.orderId(), event.items()); // local @Transactional
kafka.send("inventory-events", event.orderId().toString(),
new StockReserved(event.orderId(), event.totalAmount()));
} catch (OutOfStockException ex) {
kafka.send("inventory-events", event.orderId().toString(),
new StockRejected(event.orderId(), ex.getMessage()));
}
}
@KafkaListener(topics = "payment-events", groupId = "inventory-service")
public void onPaymentFailed(PaymentFailed event) {
inventoryService.release(event.orderId()); // compensation
kafka.send("inventory-events", event.orderId().toString(), new StockReleased(event.orderId()));
}
}Note: The message key is the orderId, so all events of one order land in the same partition and stay in order. Sending to Kafka right after a database commit can still lose the event if the app crashes in between; the outbox pattern (two topics ahead) fixes this.
Pros and Cons
| Pros | Cons |
|---|---|
| Simple for 2–4 steps | Hard to see the whole flow: it is spread across services |
| Loose coupling: services only know events | Risk of cyclic dependencies between services |
| No single point of failure | Hard to answer "what state is order 1002 in?" |
| Easy to add a new listener (e.g. loyalty points) | Testing the full flow needs every service |
Tip: Use choreography for short flows with few participants. When a flow grows past ~4 steps or needs timeouts and clear status, switch to orchestration.