All articles

// Knowledge.log — 技術記事

Saga Pattern: Choreography or Orchestration for Distributed Transactions Without 2PC

After the service split: coordinate order, payment, and inventory without 2PC, pick choreography or orchestration, and prove compensation actually ran.

An order comes in, needs to charge the payment and reserve inventory — and those two steps live in services with their own deploy, database, and lifecycle. If inventory refuses after the payment has already been captured, someone has to undo the payment, and there's no single COMMIT tying the two databases together. This article assumes the service cut already happened — if that decision is still open, bounded contexts is the prior article — and covers what comes next: how to coordinate the transaction without two-phase commit (2PC), when to use events versus a central orchestrator, and how to prove compensation actually ran when a step fails.

Why 2PC Doesn't Scale Across Independently Deployed Services

The temptation is to replicate what the database already solves on its own: a coordinator tells everyone to prepare, everyone confirms, or everyone rolls back. That's exactly what the two-phase commit protocol does — and it's still specified and functional for XA resources under a single transaction manager. Java SE 25's XAResource documents the contract: "at transaction commit time, the resource managers are informed by the transaction manager to prepare, commit, or rollback a transaction according to the two-phase commit protocol" — prepare(Xid) is the vote, and resources stay held until the manager decides. The Jakarta Transactions 2.0 spec describes the same design: the TransactionManager invokes XAResource.prepare and then XAResource.commit on each resource in the group.

The problem isn't the protocol — it's applying it across services that nobody deploys, versions, or restarts together. Chris Richardson sums it up on the saga pattern page: "2PC is not an option," and expands on it in the first part of the series: using 2PC in a microservice architecture "is generally a bad idea. It's a form of synchronous communication that results in runtime coupling that significantly impacts the availability of an application." AWS's pattern guide reaches the same conclusion for database-per-service: "the two-phase commit is not an option" because "each transaction is distributed across various databases, and there is no single controller that can coordinate a process that's similar to the two-phase commit." Four concrete problems show up across these sources, not one generic "2PC is bad":

  • Synchronous blocking. Prepare-then-commit is a synchronous round trip; participants wait for the coordinator to decide (Richardson: "runtime coupling").
  • Coordinator availability. Without a single live controller, prepared transactions get stuck (AWS: "no single controller"; the recovery path via XAResource.recover exists precisely because branches end up "in prepared or heuristically completed states" when the coordinator goes down).
  • Mixed technology. Only resources that implement XAResource can participate — databases, queues, or HTTP services outside that contract don't join the same global Xid.
  • Long-held resources. prepare keeps locks held until commit or rollback arrives; a slow (or down) coordinator holds those resources indefinitely.

The saga trades that for a different premise: each step commits locally, without waiting on anyone. If a later step fails, the earlier work "is already committed" (Richardson, part 1) — and undoing it means a new compensating transaction, not a rollback.

Choreographed Saga: Events, No Central Coordinator

In choreography, "each local transaction publishes domain events that trigger local transactions in other services" (Richardson) — "there isn't a central coordinator"; services "subscribe to each other's events." Microsoft describes the same design as services that "exchange events without a centralized controller."

Happy path for order → payment → inventory:

  1. OrderService writes the order as PENDING and publishes OrderPlaced{sagaId, orderId, amount}.
  2. PaymentService, listening for OrderPlaced, captures the payment and publishes PaymentCaptured{sagaId, paymentId}.
  3. InventoryService, listening for PaymentCaptured, reserves the stock and publishes InventoryReserved{sagaId}.
  4. OrderService, listening for InventoryReserved, confirms the order and publishes OrderConfirmed{sagaId}.

When inventory refuses after the payment has already been captured:

  1. InventoryService publishes InventoryRejected{sagaId, reason}.
  2. PaymentService, listening for InventoryRejected, runs the compensation (refund) and publishes PaymentRefunded{sagaId}.
  3. OrderService, listening for InventoryRejected or PaymentRefunded, rejects the order and publishes OrderRejected{sagaId}.

No service calls another directly; each one reacts to a domain event that already happened. Microsoft states the same compensation rule: on failure, the service "publishes a failure message. Services that subscribe to that message can run predefined compensating actions." The gain is decoupling — the cost is that no single place shows the whole flow; it's spread across handlers in three different services.

Orchestrated Saga: A Central State Machine

In orchestration, "an orchestrator (object) tells the participants what local transactions to execute" (Richardson). Communication becomes command/reply: the orchestrator "sends a command message," processes the reply, and decides the next step. It also persists the saga's progress and, on failure, "executes the compensating transactions in bottom to top order" (part 4 of the series). Microsoft describes the same role: a "centralized controller ... stores and interprets the states of each task, and handles failure recovery by using compensating transactions."

Same flow, now orchestrated:

  1. The API creates the saga instance (sagaId), writes the order as PENDING.
  2. The orchestrator sends the CapturePayment command; PaymentService replies PaymentCaptured; the orchestrator records the step.
  3. The orchestrator sends ReserveInventory; InventoryService replies. On InventoryReserved, the orchestrator confirms the order and marks the saga COMPLETED.

If the reply is a failure, the orchestrator sends RefundPayment, waits for PaymentRefunded, rejects the order locally, and marks the saga COMPENSATED. The point that tends to confuse people: participants don't subscribe to each other's domain events — they receive commands and reply only to the orchestrator. It's that difference in message ownership, not the database layout, that separates the two styles.

An Objective Criterion, Not Team Preference

The same sources give checkable tests, not "team preference":

CriterionChoose choreography when...Choose orchestration when...
Number of participantsFew services, no coordination logic needed (Microsoft; AWS: "only a few participants")Complex flow or one that grows over time (Microsoft); Richardson: logic "scattered around the participating services" becomes hard to follow
Process ownershipNobody needs to own the process — it's pure collaborationOne service (here, Order) is the natural owner of the sequence
CouplingAccepts coupling to event contracts and the risk of a cyclic dependencyParticipants couple only to the command/reply contract with the orchestrator; avoids cycles
Visibility into progressAccepts not having a single view of what's in flightNeeds a queryable state machine — "stores and interprets the states of each task"
Failure domainA central process service would be an unacceptable SPOFAccepts (and hardens) a single coordinator; AWS admits it "can become a single point of failure"

Practical rule for this article's case: three participants with a clear process owner (Order) → orchestration as the default. Choreography makes sense when the team explicitly wants no process service and can keep the event graph small and acyclic — the risk both Microsoft and AWS call out is exactly PaymentService reacting to an event and unintentionally republishing something OrderService already listens for, closing a cycle.

Observing Compensation: How to Prove It Ran

"Sagas restored consistency" isn't a claim you verify by reading the handler code — it's one you verify by reading the log. Microsoft is direct about this in the compensating transaction pattern: the infrastructure needs to "reliably monitor compensation logic progress" and let you "correlate and audit both the original operation and its compensation end-to-end"; compensating steps "can fail" and need to be idempotent. Four signals cover this without requiring distributed tracing:

  1. sagaId on every message and every log line — command, event, reply.
  2. Append-only step log: sagaId, step, dir=FORWARD|COMPENSATE, ok. In orchestration this log is the persisted state machine; in choreography it's the only way to reconstruct the flow afterward, since no single service sees the whole thing.
  3. Outcome events, not just *Failed: publish PaymentRefunded, InventoryReleased, OrderRejected — the success of compensation is information as important as the original failure.
  4. Compensation counter (a LongAdder is enough in production): compensation actually ran if and only if there's a COMPENSATE line with ok=true and the matching outcome event for that sagaId. Alert if FORWARD failed and no COMPENSATE shows up afterward.

Practical Example in Java 25

The demo below runs in-process, no broker: a Bus publishes/subscribes by message type, the orchestrator sends commands and reacts to replies, and SagaLog is both the structured log and the source of truth for compensation. It compiles and runs directly with java SagaDemo.java (Java 25, no preview, no external dependency).

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Consumer;

public final class SagaDemo {
    public static void main(String[] args) {
        Bus bus = new Bus();
        SagaLog log = new SagaLog();

        Set<String> paymentShouldFail = Set.of();
        Set<String> outOfStock = Set.of("saga-2");

        new PaymentService(bus, paymentShouldFail);
        new InventoryService(bus, outOfStock);
        Orchestrator orchestrator = new Orchestrator(bus, log);

        System.out.println("--- saga-1: caminho feliz ---");
        orchestrator.start("saga-1", "order-1");

        System.out.println("--- saga-2: estoque recusa, compensa pagamento e pedido ---");
        orchestrator.start("saga-2", "order-2");

        System.out.println("--- saga-2: mesma mensagem de novo (reentrega) ---");
        bus.publish(new Msg("cmd.reserveInventory", "saga-2", "order-2", ""));

        System.out.printf("compensacoes ok = %d%n", log.compensateOkCount());
        System.out.printf("compensated(saga-1) = %s%n", log.compensated("saga-1"));
        System.out.printf("compensated(saga-2) = %s%n", log.compensated("saga-2"));
    }
}

record Msg(String type, String sagaId, String orderId, String detail) {}

final class Bus {
    private final Map<String, List<Consumer<Msg>>> handlers = new ConcurrentHashMap<>();

    void on(String type, Consumer<Msg> handler) {
        handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
    }

    void publish(Msg m) {
        for (Consumer<Msg> h : handlers.getOrDefault(m.type(), List.of())) h.accept(m);
    }
}

final class SagaLog {
    enum Dir { FORWARD, COMPENSATE }
    record Line(String sagaId, String step, Dir dir, boolean ok) {}

    private final List<Line> lines = new CopyOnWriteArrayList<>();
    private final Set<String> seen = ConcurrentHashMap.newKeySet();
    private final LongAdder compensateOk = new LongAdder();

    void record(String sagaId, String step, Dir dir, boolean ok) {
        if (!seen.add(sagaId + ":" + step + ":" + dir)) {
            System.out.printf("sagaId=%s step=%s dir=%s ok=%s duplicate=true%n", sagaId, step, dir, ok);
            return;
        }
        lines.add(new Line(sagaId, step, dir, ok));
        System.out.printf("sagaId=%s step=%s dir=%s ok=%s%n", sagaId, step, dir, ok);
        if (dir == Dir.COMPENSATE && ok) compensateOk.increment();
    }

    boolean compensated(String sagaId) {
        return lines.stream().anyMatch(l -> l.sagaId().equals(sagaId) && l.dir() == Dir.COMPENSATE && l.ok());
    }

    long compensateOkCount() { return compensateOk.sum(); }
}

final class PaymentService {
    private final Bus bus;
    private final Set<String> shouldFail;
    private final Set<String> captureAttempted = ConcurrentHashMap.newKeySet();
    private final Set<String> refundAttempted = ConcurrentHashMap.newKeySet();

    PaymentService(Bus bus, Set<String> shouldFail) {
        this.bus = bus;
        this.shouldFail = shouldFail;
        bus.on("cmd.capturePayment", this::onCapture);
        bus.on("cmd.refundPayment", this::onRefund);
    }

    private void onCapture(Msg m) {
        if (!captureAttempted.add(m.sagaId())) return;
        if (shouldFail.contains(m.sagaId())) {
            bus.publish(new Msg("reply.paymentFailed", m.sagaId(), m.orderId(), "cartao-recusado"));
            return;
        }
        bus.publish(new Msg("reply.paymentCaptured", m.sagaId(), m.orderId(), ""));
    }

    private void onRefund(Msg m) {
        if (!refundAttempted.add(m.sagaId())) return;
        bus.publish(new Msg("reply.paymentRefunded", m.sagaId(), m.orderId(), ""));
    }
}

final class InventoryService {
    private final Bus bus;
    private final Set<String> outOfStock;
    private final Set<String> reserveAttempted = ConcurrentHashMap.newKeySet();

    InventoryService(Bus bus, Set<String> outOfStock) {
        this.bus = bus;
        this.outOfStock = outOfStock;
        bus.on("cmd.reserveInventory", this::onReserve);
    }

    private void onReserve(Msg m) {
        if (!reserveAttempted.add(m.sagaId())) return;
        if (outOfStock.contains(m.sagaId())) {
            bus.publish(new Msg("reply.inventoryRejected", m.sagaId(), m.orderId(), "sem-estoque"));
            return;
        }
        bus.publish(new Msg("reply.inventoryReserved", m.sagaId(), m.orderId(), ""));
    }
}

final class Orchestrator {
    private final Bus bus;
    private final SagaLog log;

    Orchestrator(Bus bus, SagaLog log) {
        this.bus = bus;
        this.log = log;
        bus.on("reply.paymentCaptured", this::onPaymentCaptured);
        bus.on("reply.paymentFailed", this::onPaymentFailed);
        bus.on("reply.inventoryReserved", this::onInventoryReserved);
        bus.on("reply.inventoryRejected", this::onInventoryRejected);
        bus.on("reply.paymentRefunded", this::onPaymentRefunded);
    }

    void start(String sagaId, String orderId) {
        bus.publish(new Msg("cmd.capturePayment", sagaId, orderId, ""));
    }

    private void onPaymentCaptured(Msg m) {
        log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.FORWARD, true);
        bus.publish(new Msg("cmd.reserveInventory", m.sagaId(), m.orderId(), ""));
    }

    private void onPaymentFailed(Msg m) {
        log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.FORWARD, false);
        log.record(m.sagaId(), "ORDER", SagaLog.Dir.COMPENSATE, true);
    }

    private void onInventoryReserved(Msg m) {
        log.record(m.sagaId(), "INVENTORY", SagaLog.Dir.FORWARD, true);
        log.record(m.sagaId(), "ORDER", SagaLog.Dir.FORWARD, true);
    }

    private void onInventoryRejected(Msg m) {
        log.record(m.sagaId(), "INVENTORY", SagaLog.Dir.FORWARD, false);
        bus.publish(new Msg("cmd.refundPayment", m.sagaId(), m.orderId(), ""));
    }

    private void onPaymentRefunded(Msg m) {
        log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.COMPENSATE, true);
        log.record(m.sagaId(), "ORDER", SagaLog.Dir.COMPENSATE, true);
    }
}

For saga-2, the log sequence is PAYMENT FORWARD ok=trueINVENTORY FORWARD ok=falsePAYMENT COMPENSATE ok=trueORDER COMPENSATE ok=true. At the end, compensateOkCount() == 2 and compensated("saga-2") == true; compensated("saga-1") stays false, because nothing needed to be undone. The manual redelivery of cmd.reserveInventory for saga-2 doesn't generate a new event: reserveAttempted.add already returned false on the first attempt, so the handler exits without publishing — idempotency lives in the service, not just in the log. SagaLog has its own duplicate check (seen) as a second layer, in case two orchestrators (or a redelivery at some other point in the flow) try to write the same sagaId:step:dir twice.

Choreography would use the same Bus, but without the Orchestrator class: each service would subscribe to domain events instead of commands.

// PaymentService assina o evento de domínio, não um comando do orquestrador
bus.on("OrderPlaced", m -> capturarPagamentoEPublicar("PaymentCaptured", m));
bus.on("InventoryRejected", m -> estornarEPublicar("PaymentRefunded", m));

// InventoryService reage ao evento do serviço anterior na cadeia
bus.on("PaymentCaptured", m -> reservarEPublicar("InventoryReserved", m));

The architecture swap happens entirely in the wiring (bus.on): who subscribes to what. SagaLog and the idempotency logic don't change — the observability described in the previous section works for both styles.

Common Pitfalls

  • Treating compensation as a rollback. The earlier step already committed locally (Richardson: "already committed"); Microsoft reinforces that "data can't be rolled back because saga participants commit changes." Fix: model compensation as a new business transaction (refund, cancellation), not as undoing an UPDATE. Watch the pair FORWARD ok=true followed by COMPENSATE ok=true in the log — if only the first one exists, compensation never finished.
  • Dual-write without an outbox. Writing local state and publishing the event are two operations; one can fail after the other. That's messaging infrastructure territory, covered in idempotency and outbox — here it's just a heads-up that the in-process demo doesn't have this problem because there's no dual-write.
  • Non-idempotent compensation. Message redelivery (queue reconnect, client retry) can't refund twice. Fix: the handler checks an attempt set per sagaId before acting, like captureAttempted/refundAttempted/reserveAttempted above. Watch by counting how many times duplicate=true shows up in the log — if the number grows, some producer is redelivering more than it should.
  • Cycles in choreography. If PaymentService reacts to InventoryRejected by publishing something OrderService already listens to as a trigger for a new payment, the event graph closes a cycle. Fix: draw the event graph before coding and confirm it's acyclic; if it isn't, that's a sign the saga needs an orchestrator. Watch by tracing sagaId across the published event types — a sagaId that shows up more times than the flow has steps means there's a cycle.
  • Orchestrator as an unstated SPOF. The demo above runs in-process and loses everything if the process dies mid-saga. In production, the orchestrator's state (the SagaLog itself) needs to survive a process restart. Watch by monitoring sagas whose FORWARD step was recorded longer ago than the flow's SLA, with no following step — that's the sign of an orchestrator that died mid-flow.
  • Phantom isolation. Between PaymentCaptured and InventoryReserved, the order exists as PENDING and some other flow might try to operate on it. Keep the PENDING state explicit until confirmation or rejection — it's the "semantic lock" a saga doesn't resolve on its own (that's domain-modeling territory, already covered in bounded contexts, not repeated here).

Recommendation

For the case of three participants with a clear process owner — order, payment, inventory, with Order as the service that starts and closes the flow — orchestration is the default: it gives you a single view of progress, avoids event cycles, and keeps compensation logic in one auditable place. DevDojo would switch to choreography only when the team deliberately wants no process service (few participants, no plan to grow) and can keep the event graph small and acyclic — in that case, the decoupling gain outweighs the loss of central visibility. In either style, "it worked" isn't defined by the code compiling: it's sagaId showing up on every message, the step log recording FORWARD and COMPENSATE append-only, and the outcome event (PaymentRefunded, not just InventoryRejected) existing to prove compensation actually finished.

javaarchitecture

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles