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.
| Action | Compensation | Not equivalent because |
|---|---|---|
| Reserve inventory | Release reservation | the stock may have been sold in between |
| Charge card | Issue refund | the refund is a separate visible transaction |
| Allocate courier slot | Cancel booking | the slot may already be reallocated |
| Send email | nothing | it 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.
// SPONSORSHIP
If this research saved you time or improved your architecture, consider sponsoring my work on GitHub. All sponsorships go directly toward infrastructure and further technical research.
[ Become a Sponsor ]