Skip to content

Cloud & Infrastructure · Architecture Patterns

The Saga Pattern: Distributed Transactions Without Two-Phase Commit

When a business transaction spans multiple services, you can't wrap it in one database transaction anymore. The saga pattern solves this with a chain of local transactions and compensating actions. Here's how it works and when it earns its complexity.

Prathviraj Singh

Prathviraj Singh

7 min read

The Saga Pattern: Distributed Transactions Without Two-Phase Commit

Sponsored

Share

A saga is what you build when you can no longer wrap a business transaction in BEGIN and COMMIT, because the steps of that transaction live in different services with different databases. It trades the clean guarantees of a single ACID transaction for something weaker but achievable across a distributed system: a sequence of local transactions, each one committed on its own, with a compensating action ready for every step in case something later in the chain fails.

The problem it responds to

Imagine an order flow: reserve inventory, charge a payment, schedule shipping. In a monolith with one database, this is a single transaction. If the payment fails, the whole thing rolls back automatically, and the inventory reservation never really happened as far as any other part of the system can tell.

Split those three responsibilities into separate services, each owning its own database (a common outcome of a microservices migration), and that automatic rollback disappears. There is no database-level transaction that spans a payments database and an inventory database at two different services. If the payment step fails after inventory has already been reserved, something has to explicitly release that reservation. Nothing does it for you anymore.

Monolith, single database:
  BEGIN
    reserve_inventory()
    charge_payment()     -- fails
  ROLLBACK  -- inventory reservation undone automatically

Microservices, separate databases:
  reserve_inventory()  -- committed, in Inventory Service's own database
  charge_payment()     -- fails, in Payment Service
  -- inventory reservation is still committed. Nothing rolled it back.

The saga pattern is the explicit answer to that gap: a defined sequence of local transactions, each with a corresponding compensating transaction that undoes its real-world effect if a later step in the saga fails.

Choreography: services react to events

In a choreography-based saga, there’s no central coordinator. Each service publishes an event when it finishes its step, and the next service in the chain listens for that event and acts on it.

Diagram showing four services, Order, Payment, Inventory, Shipping, connected by event arrows in a choreography-based saga with no central coordinator

// Inventory Service, listening for OrderCreated
eventBus.on('OrderCreated', async (order) => {
  const reserved = await reserveInventory(order.items);
  if (reserved) {
    eventBus.emit('InventoryReserved', order);
  } else {
    eventBus.emit('InventoryReservationFailed', order);
  }
});

// Payment Service, listening for InventoryReserved
eventBus.on('InventoryReserved', async (order) => {
  const charged = await chargePayment(order.paymentInfo);
  if (charged) {
    eventBus.emit('PaymentCharged', order);
  } else {
    eventBus.emit('PaymentFailed', order); // triggers inventory compensation
  }
});

// Inventory Service, listening for its own compensation trigger
eventBus.on('PaymentFailed', async (order) => {
  await releaseInventoryReservation(order.items); // compensating action
});

This is simple to stand up and has no single point of failure, since there’s no coordinator to go down. The cost shows up as the saga grows: the actual business flow, “what happens when an order is placed,” now lives implicitly across every service’s event handlers. Understanding the full flow means reading code in four different places, and adding a fifth step means touching event handlers in services that may not obviously relate to each other at a glance.

Orchestration: a coordinator commands each step

In an orchestration-based saga, a dedicated coordinator service explicitly calls each step in sequence and tracks the saga’s state.

Diagram showing an orchestrator service coordinating Order, Payment, Inventory, and Shipping services, each communicating directly with the orchestrator rather than each other

// OrderSagaOrchestrator
async function runOrderSaga(order) {
  const sagaId = await sagaStore.create(order);

  try {
    await sagaStore.markStep(sagaId, 'inventory', 'started');
    await inventoryService.reserve(order.items);
    await sagaStore.markStep(sagaId, 'inventory', 'done');

    await sagaStore.markStep(sagaId, 'payment', 'started');
    await paymentService.charge(order.paymentInfo);
    await sagaStore.markStep(sagaId, 'payment', 'done');

    await sagaStore.markStep(sagaId, 'shipping', 'started');
    await shippingService.schedule(order.shippingInfo);
    await sagaStore.markStep(sagaId, 'shipping', 'done');

    await sagaStore.complete(sagaId);
  } catch (err) {
    await compensate(sagaId, order); // walks completed steps backward
  }
}

async function compensate(sagaId, order) {
  const completed = await sagaStore.getCompletedSteps(sagaId);
  if (completed.includes('payment')) await paymentService.refund(order.paymentInfo);
  if (completed.includes('inventory')) await inventoryService.release(order.items);
  await sagaStore.markFailed(sagaId);
}

The whole flow is visible in one place, which makes debugging a failed order far easier: you query the saga store for the order’s saga ID and see exactly which steps completed and which failed. The cost is that the orchestrator is now a new component every saga depends on, and it needs its own reliability story (the sagaStore above has to survive the orchestrator process restarting mid-saga).

Choosing between them

ChoreographyOrchestration
CoordinationImplicit, via events each service reacts toExplicit, via a central coordinator
Single point of failureNoneThe orchestrator
DebuggabilityFlow spread across services’ event handlersFlow visible in one place, the saga store
Adding a new stepTouch event handlers in multiple servicesTouch the orchestrator’s step sequence
Best fitSmall sagas, 2-3 steps, low change frequencyLarger sagas, frequent changes, need for visibility

Most teams start with choreography because it requires no new component, and migrate to orchestration once a saga grows past 3-4 steps or once debugging failed orders becomes a recurring support burden. There’s no rule that says you have to pick one style for every saga in your system; different business transactions can use different coordination styles based on their own complexity.

Compensation is not rollback

The detail that catches people coming from single-database transactions: compensating actions don’t undo a change, they reverse its effect, and those are not the same thing once real-world side effects are involved.

  • charge_payment compensates with refund_payment, not “un-charge.” The charge already happened; a refund is a new, separate transaction.
  • send_confirmation_email may have no meaningful compensation at all. You cannot un-send an email. Some saga steps are simply not compensatable, and the saga design has to either avoid putting them before points of likely failure, or accept that specific failure mode as a known limitation.
  • reserve_inventory compensates cleanly with release_inventory, because a reservation, unlike a payment or a sent email, hasn’t yet had an external effect beyond your own system.

Designing a saga means going through every step and asking what its compensating action actually does, not assuming a generic “undo” exists for every operation.

What makes sagas survivable in production

Two things matter more than the choreography-versus-orchestration choice once a saga is running in production:

Idempotency. Every step and every compensating action needs to be safe to retry, because failures in a distributed system mean you will retry things. A network timeout doesn’t tell you whether the payment charge succeeded before the timeout; retrying charge_payment without idempotency risks a double charge. The idempotency key pattern is exactly the tool for this: attach a stable key to each saga step’s request so a retried call is recognized as a duplicate rather than executed twice.

Persisted saga state. If the orchestrator process crashes mid-saga (or, in choreography, if an event gets lost), something needs to know which steps completed so recovery can resume or compensate correctly. That state has to survive process restarts, in a database, not in memory.

When not to bother

If every service touched by a transaction shares one database, use a normal transaction. It gives you real atomicity and isolation, guarantees a saga fundamentally cannot provide, and it’s simpler to reason about and debug. Sagas are a response to a genuine constraint, data that can’t live inside one transactional boundary, not a default pattern to reach for because the word “microservices” is in the room. If you’re evaluating whether your own service boundaries are drawn correctly in the first place, that’s a question worth answering before deciding how to coordinate transactions across them.

Start by mapping which of your business transactions actually cross service boundaries today. For the ones that do, choreography is the lower-cost starting point for two or three steps; move to orchestration once you can point at a specific debugging pain the implicit event flow is causing you.

Frequently asked questions

What problem does the saga pattern actually solve?
It solves the problem of a business transaction that has to touch multiple services, each with its own database, where a traditional single-database ACID transaction is no longer possible. Placing an order might need to reserve inventory, charge a payment, and schedule shipping, three services, three databases. A saga breaks that into a sequence of local transactions, one per service, coordinated so that if a later step fails, earlier steps get compensated rather than left in an inconsistent partial state.
What's the difference between choreography and orchestration sagas?
In choreography, each service listens for events from the previous step and decides what to do next, with no central coordinator. It's simple to start with and has no single point of failure, but the overall flow lives implicitly across every service's event handlers, which gets hard to trace as steps multiply. In orchestration, a dedicated coordinator service explicitly calls each step and tracks saga state centrally. The flow is visible in one place and easier to debug, at the cost of the coordinator becoming a new component every saga depends on.
Is compensation the same thing as a database rollback?
No, and this is the detail that trips people up coming from single-database transactions. A rollback undoes a change that was never committed elsewhere. Compensation reverses the effect of a change that has already been committed and may have already had real-world consequences, an email already sent, an inventory hold already placed. The compensating action for 'charge the customer' isn't 'un-charge,' it's 'issue a refund,' which is a different operation with its own failure modes, not a database-level undo.
Do I need event sourcing or a message bus to implement sagas?
Not necessarily. Choreography-based sagas usually do use a message bus or event stream, since that's how services react to each other's events. Orchestration-based sagas can work with plain HTTP calls from the orchestrator to each service, with the orchestrator's own database tracking saga state. Event sourcing is a complementary pattern, useful for replaying and auditing saga history, but it's not a hard requirement for either style.
When should I not use the saga pattern?
When your transaction doesn't actually cross a service or database boundary. If everything the transaction touches lives in one database, a normal ACID transaction is simpler, faster, and gives you real atomicity and isolation, guarantees a saga can't give you. Sagas are a response to a genuine constraint (data that can't live in one transactional boundary), not a default architecture to reach for because microservices are involved.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored