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 ------|
  CANCELLED

Example: 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

ProsCons
Simple for 2–4 stepsHard to see the whole flow: it is spread across services
Loose coupling: services only know eventsRisk of cyclic dependencies between services
No single point of failureHard 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.