You've picked SAGA - now you need an implementation

If a distributed transaction needs to touch order, payment, and inventory services and a single @Transactional boundary isn't an option, a SAGA - a sequence of local transactions with compensating actions for rollback - is the standard fix. Deciding that is only step one. The next decision is how each step in the SAGA finds out it's its turn: does every service just listen for events and react, or does one service tell everyone else exactly what to do and when? These are choreography and orchestration, and they produce systems with very different failure modes, even when they implement the exact same business flow.

Choreography: no one is in charge

In a choreographed SAGA, there's no central coordinator. Each service publishes an event when it finishes its local transaction, and the next service in line is simply subscribed to that event and reacts to it. Order Service publishes OrderCreated. Payment Service, listening for that event, charges the card and publishes PaymentCompleted. Inventory Service, listening for that, reserves stock and publishes InventoryReserved. No service knows the whole flow - each one only knows "when I see event X, do Y and emit event Z."

// Payment Service - reacts to an event it doesn't control the timing of
eventBus.subscribe('OrderCreated', async (event) => {
  try {
    const charge = await paymentGateway.charge(event.orderId, event.amount);
    await eventBus.publish('PaymentCompleted', { orderId: event.orderId, chargeId: charge.id });
  } catch (err) {
    await eventBus.publish('PaymentFailed', { orderId: event.orderId, reason: err.message });
  }
});

What choreography gets you

No single point of failure for coordination logic. Services stay loosely coupled - Payment Service has zero knowledge that Inventory Service even exists. Adding a new step (say, a fraud-check service) means subscribing it to an existing event - nothing else has to change.

What it costs you

There is no single place that shows you the state of an in-flight SAGA. Answering "where is order #4471 right now?" means tailing logs or event streams across four services. Cyclical dependencies between event subscriptions are easy to introduce by accident and hard to spot in a code review.

Orchestration: one service calls the shots

In an orchestrated SAGA, a dedicated orchestrator service owns the entire flow as an explicit state machine. It calls Payment Service directly (or via command messages), waits for the result, then calls Inventory Service, and so on - deciding at each step whether to proceed or start issuing compensations. Participant services don't know about each other at all; they only know about the orchestrator.

// Order Saga Orchestrator - owns the whole flow as an explicit state machine
class OrderSagaOrchestrator {
  async run(orderId) {
    const saga = await this.sagaStore.create(orderId, 'STARTED');
    try {
      await this.paymentClient.charge(orderId);
      await this.sagaStore.update(orderId, 'PAYMENT_DONE');

      await this.inventoryClient.reserve(orderId);
      await this.sagaStore.update(orderId, 'INVENTORY_DONE');

      await this.shippingClient.schedule(orderId);
      await this.sagaStore.update(orderId, 'COMPLETED');
    } catch (err) {
      await this.compensate(orderId, saga.currentStep);
    }
  }

  async compensate(orderId, failedAtStep) {
    // Walk backwards, only undoing steps that actually completed.
    if (failedAtStep >= 'INVENTORY_DONE') await this.inventoryClient.release(orderId);
    if (failedAtStep >= 'PAYMENT_DONE') await this.paymentClient.refund(orderId);
    await this.sagaStore.update(orderId, 'COMPENSATED');
  }
}

What orchestration gets you

One place to look for the current state of any SAGA instance - the orchestrator's state store. Compensation logic lives in one file instead of being scattered across every participant's event handlers. Adding conditional branching (skip fraud check for repeat customers, say) is a straightforward if-statement rather than a rearrangement of event subscriptions.

What it costs you

The orchestrator becomes a service every other service depends on, and a bug in it can stall every in-flight SAGA at once. It also needs its own durable state store and its own recovery logic for what happens if the orchestrator crashes mid-flow - that's real infrastructure, not a config option.

The decision, as a table

Question                                  Favors
-----------------------------------------  ------------------
3+ services, complex branching logic?      Orchestration
Need a single dashboard/status view?       Orchestration
Steps mostly linear, 2-3 services?         Choreography
New participants added frequently?         Choreography
Team wants explicit, testable state logic? Orchestration
Want to avoid a new coordinator service?   Choreography

In practice, most teams start with choreography because it feels lighter to build - no new service, just event subscriptions - and migrate to orchestration once the SAGA grows past four or five steps and debugging "why is this order stuck" starts eating real engineering time. That migration is a rewrite, not a refactor, so it's worth erring toward orchestration earlier than instinct suggests if you already know the flow has more than a couple of branches.

A hybrid that works: orchestrate the SAGA, publish events anyway

These aren't mutually exclusive. A common production pattern is an orchestrator that owns the flow's control logic but still publishes domain events (OrderCompleted, OrderCancelled) for other, unrelated systems - analytics, notifications, audit logging - to consume via choreography. The orchestrator coordinates the transactional steps; everything downstream that doesn't need to participate in the transaction just listens.

Key takeaways

  • Choreography: each service reacts to events with no central coordinator - loosely coupled, but no single place to see SAGA state.
  • Orchestration: a dedicated service owns the flow as an explicit state machine - centralized visibility and compensation logic, at the cost of a new critical-path service.
  • Choreography suits short, linear flows with frequently added participants. Orchestration suits flows with branching logic or a hard requirement for status visibility.
  • Both approaches still need idempotent step handlers and durable compensation logic - the coordination style doesn't change that requirement.
  • A hybrid - orchestrated core transaction, choreographed side effects - is common and often the pragmatic answer.
If your SAGA has grown past three services and nobody on the team can answer "where is this transaction right now" without grepping five log files, that's the signal it's time to move from choreography to an orchestrator.