A single database transaction can't span three services without turning them back into one system with extra steps. The saga pattern is the standard answer: break a multi-step business transaction into a sequence of local transactions, each with a defined way to undo itself if a later step fails.

Choreography vs. Orchestration

In a choreographed saga, each service reacts to events from the previous step and emits its own — no central coordinator.

text
OrderService: OrderCreated
   -> PaymentService listens, charges card, emits PaymentCaptured
      -> InventoryService listens, reserves stock, emits StockReserved
         -> ShippingService listens, schedules shipment

This keeps services decoupled but makes the overall flow hard to see — there's no single place that shows "here's what a checkout does," and adding a step means touching the event contracts of whichever services sit next to it in the chain.

In an orchestrated saga, a coordinator explicitly calls each step and decides what happens on failure:

python
class CheckoutSaga:
    def run(self, order):
        try:
            payment.charge(order)
            inventory.reserve(order)
            shipping.schedule(order)
        except InventoryError:
            payment.refund(order)
            raise
        except ShippingError:
            inventory.release(order)
            payment.refund(order)
            raise

The flow is explicit and testable, at the cost of a coordinator that now knows about every participant — a small, deliberate coupling in exchange for visibility.

Compensating Actions Aren't Rollbacks

The hardest part of a saga isn't sequencing — it's that compensation is not a database rollback. Once PaymentService has captured a charge, "undoing" it is a refund: a new, visible transaction, not an erasure. Compensations have to be designed as first-class operations with their own failure modes:

  • A refund can itself fail (card expired, account closed) — the saga needs a path for compensations that don't cleanly succeed, often a manual review queue rather than an infinite retry.
  • Compensations must be idempotent, because the step that triggers them may itself be retried after a timeout.
  • Order matters: compensate in reverse of the forward sequence, undoing the most recent, least-committed step first.

Consistency You Actually Get

A saga gives you eventual consistency, not atomicity. There is a real window — milliseconds to minutes — where payment has succeeded but inventory hasn't been reserved yet, and any code or user reading state during that window sees a partial, in-progress order. Two mitigations, not mutually exclusive: mark orders in an explicit PENDING state until the saga completes so readers know not to trust it yet, and keep sagas short — the longer a saga runs, the more places a partial state can leak into a UI or another system.

When Not to Bother

If the steps genuinely all belong to one service and one database, use a real transaction — a saga is solving a distribution problem you don't have yet, and it adds compensating-action code for failure modes a ROLLBACK already handles for free. Reach for sagas only once the steps are owned by services that don't share a database, because that's the actual constraint the pattern exists to work around.