Skip to main content

The Saga Pattern in BPMN: Compensation Done Right

· 11 min read
Richard Bízik
Founder of QuantumBPM

You have a booking flow that reserves a hotel, then charges a card, then books a flight. The flight step fails. Now what? The hotel is reserved and the card is charged, but there's no trip. You can't ROLLBACK: those were three separate services, three separate transactions, already committed. Someone has to walk the whole thing back: refund the card, release the hotel, and do it in the right order.

That walk-back is the saga pattern, and most teams hand-roll it: a pile of try/catch, a list of "things I've done so far," and a reverse loop that's wrong the first three times you write it. BPMN compensation is the saga pattern as a first-class engine feature: you declare each step's undo action, and the engine handles the reverse-order rollback, the data snapshots, and the recursion for you.

Why one big transaction doesn't survive across services

A database transaction gives you atomicity for free: everything commits or nothing does. The moment your work spans two services (two databases, a payment provider, an external API), that guarantee evaporates. There is no distributed BEGIN/COMMIT you can wrap around "reserve hotel, charge card, book flight." Each one commits independently and immediately.

Two-phase commit exists in theory, but almost nobody runs it across microservices in practice: it needs every participant to hold locks and speak the same protocol, and it turns any one slow service into a system-wide stall. So the industry settled on the saga instead: give up atomicity, keep consistency by construction.

A saga is two lists:

  • Forward steps: the things you actually want to do (reserve hotel, charge card, book flight).
  • Compensating actions: the undo for each forward step (release hotel, refund card, cancel flight).

Run the forward steps in order. If step N fails, run the compensating actions for steps N-1 down to 1, in reverse. You never get atomicity, but you get eventual consistency: the system ends up either fully done or fully undone, never stranded halfway.

The catch is that the reverse-order bookkeeping is fiddly and easy to get wrong. Which steps completed? In what order? What data did each one produce that its undo needs? What if the undo itself needs a retry? Hand-rolled sagas answer these badly. This is exactly the bookkeeping BPMN compensation takes off your hands.

Compensation is the saga, modeled

In BPMN you don't write the reverse loop. You attach an undo action to each step and declare a point where rollback gets triggered. The engine owns the rest.

There are only three moving parts:

PieceBPMN elementRole in the saga
MarkerCompensation boundary event on the activityDeclares "this step is compensable" and points at its undo action
HandlerAn activity marked isForCompensation="true"The undo action itself: real work (refund, release, cancel)
TriggerCompensation intermediate throw event"Roll back now", firing the undo actions for everything completed so far

Modeling a compensable step is: draw the task, attach a compensation boundary event, draw the undo task, and connect the boundary to the undo with an association.

<bpmn:serviceTask id="book-hotel" name="Book Hotel">
<bpmn:extensionElements><quantum:taskDefinition type="book-hotel" /></bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming><bpmn:outgoing>f2</bpmn:outgoing>
</bpmn:serviceTask>

<!-- Marks book-hotel as compensable -->
<bpmn:boundaryEvent id="comp-hotel" attachedToRef="book-hotel">
<bpmn:compensateEventDefinition />
</bpmn:boundaryEvent>

<!-- The undo action, outside the normal flow, linked by association -->
<bpmn:serviceTask id="cancel-hotel" name="Cancel Hotel" isForCompensation="true">
<bpmn:extensionElements><quantum:taskDefinition type="cancel-hotel" /></bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:association associationDirection="None" sourceRef="comp-hotel" targetRef="cancel-hotel" />

The compensation boundary event is not like other boundary events. It never fires on its own and never interrupts the task it's attached to. It exists purely as a declaration (when someone throws compensation, this is how you undo me), and it only does anything after the host activity has completed. An undo action that runs before the thing it's undoing has finished would be nonsense, so the engine forbids it.

The semantics you'd otherwise get wrong

The reason to let an engine own the saga is that it enforces the four rules that hand-rolled versions routinely break:

  • Reverse completion order. Compensating a scope undoes its completed activities last-done-first-undone. Book hotel then flight, roll back, and you get cancel-flight then cancel-hotel, the order that actually matters when a later step depends on an earlier one.
  • Snapshot data. Each undo action sees the variables its host produced as they were when the host completed, not the latest process state. cancel-hotel sees the hotel booking reference from when the hotel was booked, even if a dozen variables changed afterward. No threading of "which reservation ID was that again?" through the whole process.
  • Presumed abort. Compensating a step that never completed is a no-op. You throw "undo everything" and the engine quietly skips the steps that never ran: no null checks, no "did I get this far?" flags.
  • Synchronous rollback. A compensation throw waits until every undo action it triggered has finished before the token advances. The flow after "roll back" runs only once the rollback is genuinely complete, so you can safely follow it with an error end event or a notification.

Triggering the rollback is a single throw event. With no target, it compensates everything completed in its scope, in reverse:

<!-- Undo everything completed in this scope, reverse order, wait for it -->
<bpmn:intermediateThrowEvent id="throw-comp" name="Roll Back">
<bpmn:incoming>f3</bpmn:incoming><bpmn:outgoing>f4</bpmn:outgoing>
<bpmn:compensateEventDefinition />
</bpmn:intermediateThrowEvent>
Throw formEffect
compensateEventDefinition with no activityRefUndo every completed activity in scope, reverse order
compensateEventDefinition activityRef="book-hotel"Undo only that one activity

The example: a travel-booking saga

Book a hotel, then a flight, then hold the reservation until the customer confirms. If they don't confirm within an hour, roll both bookings back: flight first, then hotel.

The travel-booking saga in the QuantumBPM modeler: Book Hotel and Book Flight each carry a compensation boundary event wired to a Cancel handler, followed by a Confirm reservation user task whose one-hour timer boundary throws compensation to roll both bookings back

<bpmn:process id="travel-saga" isExecutable="true">
<bpmn:startEvent id="start"><bpmn:outgoing>f1</bpmn:outgoing></bpmn:startEvent>
<bpmn:sequenceFlow id="f1" sourceRef="start" targetRef="book-hotel" />

<bpmn:serviceTask id="book-hotel" name="Book Hotel">
<bpmn:extensionElements><quantum:taskDefinition type="book-hotel" /></bpmn:extensionElements>
<bpmn:incoming>f1</bpmn:incoming><bpmn:outgoing>f2</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:boundaryEvent id="comp-hotel" attachedToRef="book-hotel">
<bpmn:compensateEventDefinition />
</bpmn:boundaryEvent>
<bpmn:serviceTask id="cancel-hotel" name="Cancel Hotel" isForCompensation="true">
<bpmn:extensionElements><quantum:taskDefinition type="cancel-hotel" /></bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:association associationDirection="None" sourceRef="comp-hotel" targetRef="cancel-hotel" />

<bpmn:sequenceFlow id="f2" sourceRef="book-hotel" targetRef="book-flight" />
<bpmn:serviceTask id="book-flight" name="Book Flight">
<bpmn:extensionElements><quantum:taskDefinition type="book-flight" /></bpmn:extensionElements>
<bpmn:incoming>f2</bpmn:incoming><bpmn:outgoing>f3</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:boundaryEvent id="comp-flight" attachedToRef="book-flight">
<bpmn:compensateEventDefinition />
</bpmn:boundaryEvent>
<bpmn:serviceTask id="cancel-flight" name="Cancel Flight" isForCompensation="true">
<bpmn:extensionElements><quantum:taskDefinition type="cancel-flight" /></bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:association associationDirection="None" sourceRef="comp-flight" targetRef="cancel-flight" />

<!-- Hold the bookings until the customer confirms -->
<bpmn:sequenceFlow id="f3" sourceRef="book-flight" targetRef="confirm" />
<bpmn:userTask id="confirm" name="Confirm reservation">
<bpmn:incoming>f3</bpmn:incoming><bpmn:outgoing>f4</bpmn:outgoing>
</bpmn:userTask>
<bpmn:sequenceFlow id="f4" sourceRef="confirm" targetRef="confirmed" />
<bpmn:endEvent id="confirmed" name="Confirmed" />

<!-- No confirmation within an hour → roll every booking back -->
<bpmn:boundaryEvent id="timeout" name="1h" attachedToRef="confirm">
<bpmn:outgoing>f5</bpmn:outgoing>
<bpmn:timerEventDefinition>
<bpmn:timeDuration>PT1H</bpmn:timeDuration>
</bpmn:timerEventDefinition>
</bpmn:boundaryEvent>
<bpmn:sequenceFlow id="f5" sourceRef="timeout" targetRef="throw-comp" />
<bpmn:intermediateThrowEvent id="throw-comp" name="Roll Back">
<bpmn:incoming>f5</bpmn:incoming><bpmn:outgoing>f6</bpmn:outgoing>
<bpmn:compensateEventDefinition />
</bpmn:intermediateThrowEvent>
<bpmn:sequenceFlow id="f6" sourceRef="throw-comp" targetRef="rolled-back" />
<bpmn:endEvent id="rolled-back" name="Rolled back" />
</bpmn:process>

The happy path is linear: book, book, confirm, done. The interesting path is the timeout. If the hour elapses with no confirmation, the timer boundary event interrupts the wait and fires the compensation throw. throw-comp has no target, so it compensates everything completed in the process scope, in reverse: the engine runs cancel-flight, then cancel-hotel, waits for both, and only then reaches Rolled back. cancel-hotel sees the hotel booking reference from when book-hotel completed, cancel-flight the flight reference. (The confirm task itself is still running when the timer fires, so it is cancelled, not compensated: compensation only ever undoes completed work.) You wrote four tasks, a timer, and one throw event: no reverse loop, no bookkeeping list, no snapshot plumbing.

A timeout is only one way to reach the rollback. The other common shape is an error path: wrap the steps in an embedded sub-process with an error boundary event, and have the error path throw compensation for the sub-process. Which brings us to the part that composes.

Where it goes beyond the textbook saga

The classic saga is a flat list of steps. Real processes nest (a sub-process here, a called process there), and this is where an engine-level implementation pulls ahead of anything you'd hand-roll, and where QuantumBPM pulls ahead of other engines.

Sub-processes compensate as a unit. Group a set of steps into an embedded sub-process and compensate it, and the engine recursively undoes every completed activity inside it, in reverse, even if the sub-process has no explicit handler of its own. A "book the whole trip" sub-process rolls back all its bookings together, with one throw.

Compensation propagates across call activities, into the called process. This is the differentiator. When you factor part of a process out into a separate, reusable called process, a naive engine treats the call boundary as a wall: compensation stops there, and the called process's own undo logic never runs. QuantumBPM propagates compensation into the called process and runs the child's own compensation handlers.

The behavior is deliberate and controllable:

The call activity has…Compensating it runs…
Its own compensation boundary event + handler in the parentOnly that parent-side handler, a black-box undo for the whole called process
No compensation boundaryCompensation propagates into the called process and runs the child's own handlers

An explicit handler on the call activity always wins. So you choose per call site: treat the sub-process as an opaque unit with a single undo, or let it roll back its own internals step by step.

The Camunda 8 contrast

Camunda 8 stops compensation at the call activity boundary: a called process's internal compensation simply doesn't run when the parent rolls back. In QuantumBPM it propagates by default, and you opt into the black-box behavior by attaching a boundary handler. If your saga spans reusable sub-processes, this is the difference between a rollback that's actually complete and one that silently leaves the child's effects in place. See the platform comparison for the other differences.

To make propagation safe, the engine keeps a compensable child workflow alive after it finishes its own flow, so it's still there to be undone if the parent later rolls back. The child is released and reaches Completed as soon as the parent no longer needs it. A compensable child never outlives its parent.

Why durability is the other half of the story

There's a failure mode the textbook saga hand-waves: what if the process crashes mid-rollback? You've refunded the card, and the box dies before it releases the hotel. A hand-rolled saga has to persist its own "which undo actions have I run" state and recover it on restart, and that recovery code is where sagas go to die.

QuantumBPM runs on Temporal, so every process instance is a durable workflow. The rollback is as crash-proof as the forward path: if the engine dies between cancel-flight and cancel-hotel, it resumes exactly there when it recovers. Each undo action is a recorded, retried step. You get the saga pattern and the durability guarantee that makes it trustworthy in production, without writing either. That layering is the subject of BPMN on Temporal.

The takeaway

The saga pattern is not hard to understand. It's hard to get right, because the reverse-order bookkeeping, the data snapshots, the recursion, and the crash recovery are all easy to botch and tedious to test. BPMN compensation moves every one of those into the engine:

  • You declare each step's undo action, not the rollback logic.
  • Reverse order, snapshot data, and presumed-abort are guaranteed by the runtime.
  • Nesting composes: sub-processes and called processes roll back their own internals.
  • On Temporal, the rollback survives crashes like any other durable step.

If you're reaching for the saga pattern across services, you can hand-roll it, or you can model it and let the engine be right for you.

Where to go from here