Undo Is a Business Decision

A forward chain of steps above a reverse unwind chain with one step missing

Inside a single database, failure has a tidy answer. Begin, do several things, and if anything goes wrong, roll back. The intermediate states were never visible to anyone. The world is exactly as it was.

A workflow that touches four systems has no such facility. Step one charged a card at a payment provider. Step two reserved stock in a warehouse system. Step three sent a confirmation to a customer. Step four failed. There is no transaction spanning those four, because three of them are owned by other parties who never agreed to hold a lock on your behalf, and one of them sent an email that is now in somebody’s inbox.

What is available instead is compensation: for each completed step, an action that attempts to undo its effect. Chain those in reverse and you have a saga. This is the standard pattern and it is usually presented as the distributed equivalent of rollback. It is not, and treating it as one produces systems that are wrong in ways nobody notices.

Compensation is a new event, not an erasure

A rollback removes history. A compensation adds to it. The refund exists alongside the charge; both appear on the statement. The stock release exists alongside the reservation; both appear in the audit log. The apology email exists alongside the confirmation, and the customer has read both.

This distinction matters because downstream systems and people have already reacted to the intermediate state. A fraud model saw the charge. A picker started walking towards the shelf. A customer told a colleague their order went through. Compensation cannot reach any of that. It can only add a correcting event and hope the reaction to it is symmetric, which it frequently is not — a refund three days later does not undo the overdraft the charge caused.

So the honest framing is that a saga does not restore the previous state. It moves the system to a new state that is acceptable. Deciding what “acceptable” means is not a technical question, which is why compensation logic belongs in conversations with the people who own the process rather than in an infrastructure ticket.

Steps with no inverse

Some effects cannot be compensated at all, and the design has to account for them explicitly rather than discovering them in production.

Notifications are the obvious case. An email sent is sent. The compensating action is another message, which is not an undo but an admission. Physical effects are the other: material cut, a container sealed, a vehicle dispatched, a door unlocked. Data disclosed to a third party is a third and increasingly expensive case — you can request deletion, you cannot un-disclose.

The practical rule that follows is to order the workflow by reversibility. Perform the cheaply reversible steps first and the irreversible ones last, so that a failure has the largest possible chance of occurring while everything so far can still be walked back. This often conflicts with the order that feels natural — telling the customer early is friendlier — and the conflict is worth having explicitly.

Where an irreversible step must happen early, the alternative is a two-phase shape: reserve now, commit later. Hold the stock rather than consuming it. Draft the message rather than sending it. Authorise the payment rather than capturing it. The reservation is reversible; the commit happens once every step that could fail has already succeeded.

Compensations fail too

The part most implementations get thin is that a compensating action is itself a call to a remote system, with all the same failure modes as the action it is undoing. The refund times out. The warehouse rejects the release because the item has already been picked. The compensation for step two fails while compensating step three succeeded.

Which means compensations need everything the forward path needed: retries, idempotency keys, durable state recording which have completed, and a terminal outcome when they cannot succeed. A saga that assumes its compensations always work has simply moved the unhandled failure to a place with less attention on it.

And there must be a defined end state for “the compensation could not be completed”. Retrying forever leaves the instance in limbo. The correct behaviour is almost always to stop, mark the instance as requiring intervention, and surface it to a person with the full history — which is a much better outcome than a silent inconsistency, and requires having built somewhere for such items to go.

Partial success is a real answer

There is a strong instinct that a workflow must either fully succeed or fully unwind. In multi-system processes that instinct is frequently wrong.

If step four failed because a downstream service is having a bad hour, unwinding three successful steps and asking the customer to start again is a worse outcome than pausing and continuing when the service recovers. The customer’s charge is fine. The stock reservation is fine. Nothing needs undoing; something needs waiting.

Distinguishing retry later from abandon and compensate is a judgement about the failure, and it usually maps to whether the failure is transient or terminal. Transient failures deserve patience, bounded by a deadline after which the reservation would expire anyway. Terminal ones — the card was declined, the item is discontinued, the address is invalid — deserve compensation, because waiting will not change the answer.

Workflows that compensate on any error tend to produce a lot of unnecessary refunds. Workflows that retry on any error tend to produce instances that never resolve. The classification is where the real work is.

Keeping the pairs together

The maintenance failure mode of sagas is drift. Someone adds an effect to a forward step without extending its compensation. Six months later a failure unwinds most of the effects and leaves one behind, and because failures are rare the gap goes unnoticed for a long time.

Two habits help. Keep each compensation in the same unit as the step it inverts, so a change to one is visibly a change to the other. And exercise the unwind path deliberately — periodically run instances that are designed to fail at each step, in a real environment, and check the world afterwards. Compensation code that has never executed against the current version of a downstream system is a plan rather than a mechanism.

The reason to do this is simple enough. The forward path is exercised constantly and its bugs surface quickly. The unwind path runs only on the days that are already going badly.