The saga pattern is compensating transactions with better marketing

An order flow touches four services: reserve inventory, charge the card, allocate a courier slot, send confirmation. There is no shared transaction because each service owns its own database, and a distributed transaction across four teams’ services trades availability for atomicity in a way nobody wants.

So it becomes a saga. Each step commits locally. If courier allocation fails, the payment is refunded and the inventory reservation is released.

It works. Then a customer support ticket arrives asking why they received a confirmation email for an order that does not exist, and the answer is that step four ran before step three’s failure propagated, and there is no compensating transaction for an email that has already been delivered.

Each step is visible the moment it commits

The property that defines a saga is what it gives up. A distributed transaction hides intermediate state until commit. A saga has no such phase, because each local transaction commits independently and immediately.

sequenceDiagram
    participant O as Orchestrator
    participant I as Inventory
    participant P as Payments
    participant C as Courier

    O->>I: reserve
    I-->>O: reserved (committed and visible)
    O->>P: charge
    P-->>O: charged (committed and visible)
    O->>C: allocate slot
    C-->>O: FAILED
    Note over O: no rollback exists
    O->>P: refund (a new transaction)
    O->>I: release (a new transaction)

Between the charge and the refund, the customer’s statement shows a charge for an order that will not happen. That is not a bug in the implementation. It is the model working correctly, and it is why sagas are described as sacrificing isolation rather than atomicity: you eventually get all-or-nothing, and anybody looking in the middle sees a state that a transaction would have hidden.

Whether that is acceptable is a product question rather than a technical one, and it is worth asking explicitly rather than discovering from a support ticket.

Compensation is a new action, not an undo

The mental model that causes the most trouble is thinking of compensation as rollback. It is not. A rollback discards uncommitted work. A compensating transaction is a new, forward, committed action that semantically reverses an earlier one.

ActionCompensationNot equivalent because
Reserve inventoryRelease reservationthe stock may have been sold in between
Charge cardIssue refundthe refund is a separate visible transaction
Allocate courier slotCancel bookingthe slot may already be reallocated
Send emailnothingit has been read

The last row is the one that matters. Some actions have no compensation at all, and no amount of framework support changes that.

Compensations also have their own failure modes. A refund can fail because the card is expired or the account is closed. A release can fail because the service is down. This is where the recursion has to stop: a compensation must be retried until it succeeds, because there is nothing behind it.

That makes two properties non-negotiable. Compensations must be idempotent, since they will be retried. And they should only touch data owned by the compensating service, because a compensation that depends on a third party is a compensation that can fail permanently.

When one genuinely cannot succeed, the only remaining option is a human. A saga implementation with no escalation path does not avoid that situation, it just fails to tell anybody it happened.

Ordering: put the irreversible steps last

The email problem above is a design bug rather than an implementation one, and it has a general fix.

Order the steps so that everything reversible happens first and anything irreversible happens last, after the point where failure is no longer possible.

1. reserve inventory      (reversible)
2. allocate courier slot  (reversible)
3. charge card            (reversible, but visible to the customer)
4. send confirmation      (irreversible, so it goes last)

Moving courier allocation ahead of the charge means the most likely failure happens before the customer sees any money move. Moving the email to the end means it only fires once nothing can fail.

Where an irreversible step genuinely must happen early, the usual approach is to split it: reserve now, capture later. Payment providers support this directly with an authorisation that holds funds without moving them, and the capture happens once the rest of the saga has succeeded. The authorisation expires on its own if nothing captures it, which turns a compensation into a timeout.

That reframing is worth reaching for generally. A step you can let expire is much easier to operate than one you have to actively undo.

Orchestration or choreography

Choreography means no coordinator: each service publishes events and others react. Inventory publishes Reserved, payments consumes it and publishes Charged, and so on.

It removes the central component and it makes the workflow implicit. Understanding what happens in an order requires reading every participant’s event handlers, and no single place knows how far along any given saga is. Debugging a stuck order means reconstructing the sequence from logs across four services.

Orchestration means one component owns the sequence, calls each service, tracks state, and issues compensations on failure.

public void execute(OrderSaga saga) {
    try {
        saga.record(inventory.reserve(saga.orderId(), saga.items()));
        saga.record(payments.charge(saga.orderId(), saga.total()));
        saga.record(courier.allocate(saga.orderId(), saga.address()));
        notifications.sendConfirmation(saga.orderId());   // irreversible, last
        saga.markComplete();
    } catch (StepFailedException e) {
        compensate(saga);      // walk completed steps backwards
    }
}

The state has to be durable, because the orchestrator can crash mid-saga and must resume rather than restart. That means persisting which steps completed, in the same database as the orchestrator’s own transaction, and recovering incomplete sagas on startup.

For anything beyond about three steps I would take orchestration every time. The single point of coordination is a real cost, and it buys you a single place to look when somebody asks why order 4471 is stuck, which during an incident is worth more than the architectural purity of having no coordinator.

Making it survivable

Every step and every compensation needs to be idempotent, because retries are guaranteed. That means an idempotency key or a natural key on every call the orchestrator makes, and it is the single highest value thing to get right.

Every saga needs a timeout. A step that neither succeeds nor fails leaves the saga hanging forever, and without a deadline nothing notices. The correct action on timeout is usually to compensate, treating no answer as failure, which requires the step itself to be idempotent so that a late success followed by a compensation still ends consistently.

Saga state needs to be queryable. The first question during any incident is which sagas are stuck and at which step, and answering it should be a query rather than an investigation.

And the intermediate states need to be acknowledged in the product. If a customer can see a charge for an order that is about to be cancelled, either the UI should reflect that the order is still being confirmed, or the ordering should be changed so they cannot.

What it actually costs

A saga is more code than a transaction. Every step needs a compensation, every compensation needs testing, and the failure paths are numerous and rarely exercised, which means they are the least reliable part of the system while being the part that runs during incidents.

That is the honest trade. You get availability, because each service commits independently and nothing holds locks waiting for a coordinator. You pay in complexity, in observable intermediate states, and in a set of code paths that only execute when something has already gone wrong.

I would not reach for one when a single database can hold the whole operation. The pattern exists because service boundaries made a local transaction impossible, and if the boundary is not real, neither is the need.

Frequently Asked Questions

What is the saga pattern?
A saga breaks a business transaction spanning several services into a sequence of local transactions, each committing independently in its own service. If a step fails, previously completed steps are undone by executing compensating transactions that semantically reverse them. It trades the atomicity and isolation of a distributed transaction for availability, since each service commits and releases its locks immediately instead of waiting for a coordinator.
What is the difference between orchestration and choreography in sagas?
In orchestration a central coordinator tells each service what to do and tracks progress, so the whole workflow is visible in one place and easier to debug, at the cost of a component that must be highly available. In choreography each service reacts to events published by others with no central controller, which removes the single point of coordination and makes the overall flow implicit, so understanding it means reading every participant. Orchestration is usually the better default for anything with more than three steps.
What happens when a compensating transaction fails?
It must be retried until it succeeds, because there is no further fallback. Compensations should be idempotent so retries are safe, and they should be designed to be as close to guaranteed as possible, which usually means they only touch data owned by the compensating service. When a compensation genuinely cannot succeed, such as reversing a payment to a closed account, the only remaining path is escalation to a human, and a saga implementation without that path will silently leave inconsistent state.

Sources

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb