Skip to content
All posts

The Saga Pattern: Why Distributed Transactions Need More Than a Rollback

May 28, 2026·Read on Medium·

Your database transaction stops at the service boundary. The Saga pattern is what you build after you accept that.

A checkout flow has four steps: reserve inventory, charge the card, create the shipment record, send confirmation. In a monolith backed by a single PostgreSQL database, those four operations live inside one transaction. If the shipment record fails, the whole thing rolls back. The customer sees an error. You see nothing permanently lost.

Now split those four steps across four services. The inventory service reserves the item and commits. The payment service charges the card and commits. The shipping service crashes before it writes anything. You now have a charged customer, a held item, and no shipment. Nothing failed by the individual service’s definition. Everything failed by the user’s definition.

This is the problem the Saga pattern solves. It is not elegant. It is not simple. But it is honest.

Why Your Database Transaction Stops at the Service Boundary

A database transaction gives you ACID guarantees because everything happens inside one engine with one transaction log. When you BEGIN and COMMIT, the database controls the rollback. The guarantee holds because there is one source of truth.

The moment you have two services, you have two sources of truth. Postgres on Service A has no visibility into MySQL on Service B. If you want atomicity across both, someone has to coordinate them from the outside. That coordinator is either your application logic or a protocol.

The protocol people reach for first is Two-Phase Commit (2PC). Do not.

Why Two-Phase Commit Fails in Practice

2PC works by having a coordinator ask every participant to prepare, then issue a commit or abort once all have responded. In controlled conditions, it sounds reasonable.

In production, the coordinator is a single point of failure. If it crashes after collecting prepare responses but before sending the commit decision, participants are stuck holding locks indefinitely. They promised to follow the coordinator’s decision. The coordinator is gone. Nothing can proceed until the coordinator recovers or someone intervenes manually.

Martin Fowler’s analysis of distributed system patterns is direct on this: 2PC is a blocking protocol by design. The participants cannot unilaterally decide to proceed. This makes it unsuitable for anything where individual services need to keep running regardless of what other services are doing.

The Saga pattern accepts that limitation and builds around it.

What a Saga Actually Is

The term comes from a 1987 paper by Hector Garcia-Molina and Kenneth Salem at Princeton, published in ACM SIGMOD. They defined a saga as a long-lived transaction that can be broken into a sequence of shorter local transactions, each of which commits independently. If a step fails, previously committed steps are undone by compensating transactions rather than rolled back by a database engine.

The key shift: instead of one large atomic operation, you have a sequence of smaller atomic operations. Each step either succeeds and moves forward, or fails and triggers cleanup of what came before.

A saga does not prevent failure. It provides a defined path through failure.

Approach 1: Choreography

In choreography, there is no central coordinator. Each service publishes a domain event when its local transaction succeeds. Other services subscribe to those events and execute their own transactions in response.

For the checkout flow: the inventory service publishes InventoryReserved. The payment service is listening, charges the card, publishes PaymentCharged. The shipping service is listening, creates the record, publishes ShipmentCreated. The notification service is listening and sends the confirmation.

If payment fails, the payment service publishes PaymentFailed. The inventory service is listening for that event and releases the reservation.

What choreography buys you:

  • No central coordinator to fail or scale
  • Services remain independently deployable; adding a new service means subscribing to existing events
  • Works well for high-throughput flows where steps are loosely coupled

What choreography costs you:

  • The full workflow lives nowhere. To understand what happened in a specific order, you reconstruct it from distributed event logs.
  • Adding a new step in the middle of the flow means modifying every service that currently publishes and subscribes around that gap
  • Testing the full failure path means simulating failures across multiple services at the same time
  • Timeouts are hard. If the shipping service stops responding, nobody upstream knows to wait or give up.

Netflix tried choreography at scale and described what they found: the process flow becomes “embedded” within multiple services’ code, creating assumptions about input, output and SLAs across services that were supposed to be decoupled. They open-sourced Conductor in December 2016 to solve the problem differently.

Approach 2: Orchestration

In orchestration, a central service (the orchestrator) knows the complete sequence and tells each participant what to do. It tracks state, handles timeouts and retries, and triggers compensating transactions when something goes wrong.

The orchestrator for the checkout flow sends a ReserveInventory command to the inventory service and waits. On success, it sends ChargePayment to the payment service and waits. On success, it sends CreateShipment to the shipping service. If shipping fails, the orchestrator knows exactly which steps completed and executes compensations in reverse: refund the payment, release the inventory.

What orchestration buys you:

  • The full workflow lives in one place. Debugging means reading the orchestrator’s state log.
  • Timeouts and retries are centralized. The orchestrator decides how long to wait and how many times to try.
  • Adding a new step means changing the orchestrator, not coordinating changes across services.
  • Observability is genuine. The orchestrator tracks every state transition.

What orchestration costs you:

  • The orchestrator is a new service to build, deploy and operate
  • Services now have a dependency on the orchestrator knowing about them; it’s coupling in the other direction
  • The orchestrator can become a bottleneck for all order flows if it is not designed for it
  • If the orchestrator fails mid-saga, you need persistent state storage and a recovery path

When Netflix built Conductor, they chose orchestration specifically because choreography’s coupling problems became visible at the scale they were running. By the time they open-sourced it in 2016, Conductor was already executing approximately 2.6 million process workflows internally.

Compensating Transactions: Not Rollbacks

This is where most saga implementations go wrong the first time.

A compensating transaction is not a rollback. A database rollback undoes changes as if they never happened, within the same transaction, before anything committed. A compensating transaction is a new forward-moving action that acknowledges failure and creates a corrective record.

You cannot un-charge a card. You can only issue a refund. Those are not the same operation. The original charge exists in the payment processor’s history. The refund is a new event. Both appear on the customer’s statement. The net effect is zero, but the history is not erased.

This distinction matters for design. A compensating transaction must:

  1. Be idempotent. If the network drops and your orchestrator retries the compensation, executing it twice must produce the same result. A refund that issues twice is not idempotent. You need to guard against that with an idempotency key.
  2. Be retryable at the point of failure. If the compensation itself fails halfway through, the system needs to know where it stopped and resume from there, not restart from the beginning.
  3. Handle the case where the original operation partially completed. “Reserve inventory” might have updated quantity but failed before writing the reservation record. Your compensation needs to handle both states.

The Azure Architecture Center makes the point plainly: data cannot be rolled back because saga participants already committed to their own databases. Your compensation strategy must be designed for a world where those commits are permanent.

Microsoft also describes the concept of a pivot transaction: the point of no return in a saga. Once the pivot step succeeds, compensable transactions are no longer relevant. Every step after the pivot must be retriable to completion; you cannot walk them back. Knowing where your pivot transaction is changes how you design the rest of the flow.

A Concrete Implementation in Laravel

Option A: Choreography with job chaining

Laravel’s queue system supports job chaining natively. Each job dispatches the next on success and runs compensations in its failed() method.

// Kick off the saga when an order is created
class ProcessOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue;

public function handle(): void
{
// Step 1: reserve inventory, then chain subsequent steps
dispatch(new ReserveInventoryJob($this->order))
->chain([
new ChargePaymentJob($this->order),
new CreateShipmentJob($this->order),
new SendConfirmationJob($this->order),
]);
}
}
// Each job compensates for itself if it fails
class ChargePaymentJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue;
public function handle(): void
{
// Charge the card (commits to the payment provider)
PaymentService::charge($this->order);
}
public function failed(\Throwable $e): void
{
// Compensation: release the inventory that already committed
dispatch(new ReleaseInventoryJob($this->order));
}
}

The limitation here is that each job’s failed() method only knows about one level of compensation. If the shipment job fails, CreateShipmentJob::failed() needs to trigger both a payment refund and an inventory release. The compensation dependency chain is your responsibility to encode.

Option B: Orchestration with Laravel Workflow

The laravel-workflow package implements the orchestration pattern with explicit compensation registration.

// Orchestration saga: all state lives in the workflow
class OrderSagaWorkflow extends Workflow
{
public function execute(Order $order): Generator
{
try {
// Reserve inventory and register its compensation immediately
yield ActivityStub::make(ReserveInventoryActivity::class, $order->id);
$this->addCompensation(
fn() => yield ActivityStub::make(ReleaseInventoryActivity::class, $order->id)
);

// Charge payment and register its compensation
yield ActivityStub::make(ChargePaymentActivity::class, $order->id);
$this->addCompensation(
fn() => yield ActivityStub::make(RefundPaymentActivity::class, $order->id)
);
// Pivot point: once shipment is created, no walking back
yield ActivityStub::make(CreateShipmentActivity::class, $order->id);
// Steps after the pivot must be retriable, not compensable
yield ActivityStub::make(SendConfirmationActivity::class, $order->id);
} catch (\Throwable $e) {
// Runs all registered compensations in reverse order
$this->compensate();
}
}
}

Compensations registered with addCompensation() run in reverse order of registration when compensate() is called. The refund fires before the inventory release. The package also supports setParallelCompensation(true) if your compensations are independent and you want them to run concurrently.

The Failure You Have Not Planned For

Most saga designs handle the happy path and the failure path. Fewer handle the failure-during-compensation path.

What happens if the RefundPaymentActivity itself fails? Your customer paid, the shipment failed, and now the refund is stuck. The orchestrator needs to retry the compensation. Which means the compensation must be idempotent. Which means your RefundPaymentActivity needs to check whether the refund was already issued before attempting it again.

This is not an edge case. Payment providers go down. Network partitions happen. Retries get duplicated. Building compensation logic without idempotency guards means your recovery path has its own class of bugs.

One practical approach: every compensating action writes to a saga_compensations table before it executes externally. On retry, the job checks the table first. If the compensation was already applied, it exits early with success. The external call never fires twice.

This pattern costs you an extra database write on every compensation. It costs you nothing compared to issuing a duplicate refund.

Choosing Between Choreography and Orchestration

Neither is universally better. Choose based on what you can operate.

Use choreography when:

  1. Your services are genuinely independent and the workflow is simple (three steps or fewer)
  2. You have event sourcing infrastructure already in place and your team knows how to trace distributed events
  3. Throughput matters more than observability, and you can accept harder debugging
  4. Different teams own different services and coupling through an orchestrator creates organizational friction

Use orchestration when:

  1. The workflow has more than three steps or has conditional branches
  2. You need timeouts and retry policies that span multiple services
  3. Compliance or audit requirements mean you need a clear record of every state transition
  4. You are operating a small team and cannot afford to reconstruct flow state from distributed logs during an incident at 2am

The Netflix finding is worth keeping in mind: choreography works fine when the services are truly decoupled. The problems start when the implicit assumptions between services accumulate. An orchestrator makes those assumptions explicit and centralized.

Garcia-Molina and Salem wrote this pattern for long-running database transactions in 1987. The core problem has not changed. We have just found more creative ways to run into it.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
The Saga Pattern: Why Distributed Transactions Need More Than a Rollback — Hafiq Iqmal — Hafiq Iqmal