Compensation
Compensation undoes work that already completed successfully. Unlike an error or a cancellation - which interrupt something still running - compensation reverses the effects of activities that have already finished, when those effects are no longer wanted. It is the BPMN building block for the saga pattern: instead of one big transaction, you run a sequence of steps and, if a later step fails, you walk back the earlier ones with their individual undo actions (cancel the booking, refund the card, release the reservation).
A few principles:
- Only completed activities are compensated. An activity that is still running cannot be compensated - it has to be cancelled first. Compensation acts on the past, not the present.
- Handlers run on a snapshot. A compensation handler sees the host activity's variables as they were when the host completed, not the latest process state.
- It runs in reverse. Compensating a scope undoes its completed activities in the reverse of their completion order - last done, first undone.
- Presumed abort. Compensating an activity that never completed is a no-op.
The three building blocks
Compensation is always modeled with the same three pieces:
| Piece | Element | Role |
|---|---|---|
| Trigger | Compensation intermediate throw event (compensateEventDefinition) | Asks the engine to compensate - either one named activity or everything completed in the current scope |
| Marker | Compensation boundary event attached to the activity to undo | Declares that the host activity is compensable and points at its handler |
| Handler | An activity marked isForCompensation="true", linked to the boundary by an <association> | Performs the actual undo. It sits outside the normal flow - no incoming/outgoing sequence flows |
Modeling a compensable activity
Attach a compensation boundary event to the activity, mark a handler activity with isForCompensation="true", and connect the two 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" />
A compensation boundary event is special among boundary events:
- It does not use
cancelActivity- it is neither interrupting nor non-interrupting. It never fires on its own and never cancels the host. - It fires only when compensation is explicitly thrown for the host, and only after the host has completed.
The handler can be any task type (service task, send task, etc.) or an embedded sub-process - it is real work. "Black-box compensation" simply means the engine treats it as the activity's undo, nothing about it is special beyond the isForCompensation flag.
Triggering compensation
Compensation is triggered by a compensation intermediate throw event. Because throwing is synchronous by default, the throw waits until all the handlers it triggered have finished before the token advances - so the flow after the throw runs only once the undo is complete. A compensation throw is typically followed by a none end event (to terminate the flow) or by further error handling.
<!-- Compensate everything completed in the current scope, in reverse order -->
<bpmn:intermediateThrowEvent id="throw-comp" name="Undo Everything">
<bpmn:incoming>f1</bpmn:incoming>
<bpmn:outgoing>f2</bpmn:outgoing>
<bpmn:compensateEventDefinition />
</bpmn:intermediateThrowEvent>
| Form | Effect |
|---|---|
compensateEventDefinition with no activityRef | Compensates every completed activity in the throw's scope, in reverse completion order |
compensateEventDefinition activityRef="book-hotel" | Compensates only that specific completed activity |
Scope
A throw only compensates activities visible in its own scope (the process, or the embedded sub-process it sits in) and nested sub-processes - it cannot reach activities in a parent scope or in a sibling branch. This is what makes the saga composable: an embedded sub-process can compensate its own steps without affecting the rest of the process.
Compensating sub-processes
An embedded sub-process is compensable as a unit. If you compensate a sub-process, the engine recursively compensates every completed activity inside it - in reverse order - even when the sub-process itself has no explicit handler. This "default compensation" is how a grouped set of steps is undone together.
Compensating call activities
A call activity participates in compensation in one of two mutually exclusive ways:
| The call activity has… | Compensating it runs… |
|---|---|
| Its own compensation boundary event + handler (handler in the parent) | Only that parent-side handler. It defines the call activity's black-box compensation, the called process's internal compensation is not invoked. |
| No compensation boundary | Compensation propagates into the called process and runs the child's own compensation handlers. |
An explicit handler on the call activity always wins over propagation.
Propagating compensation into a called process is intentional - Camunda 8 stops compensation at the call activity. To get the Camunda behavior, attach a compensation boundary handler to the call activity itself.
Because a compensable child must remain available until the parent might compensate it, the engine keeps the child workflow alive after it finishes its own flow. It is released and reaches Completed as soon as the parent no longer needs it - when the parent reaches a terminal state, or when the call activity's output mapping raises an incident. A compensable child never outlives its parent.
A worked example: travel booking saga
Book a hotel, then a flight. If anything downstream fails, undo both - flight first, then hotel.
<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" />
<!-- Something went wrong downstream → undo all completed bookings -->
<bpmn:sequenceFlow id="f3" sourceRef="book-flight" targetRef="throw-comp" />
<bpmn:intermediateThrowEvent id="throw-comp" name="Roll Back">
<bpmn:incoming>f3</bpmn:incoming><bpmn:outgoing>f4</bpmn:outgoing>
<bpmn:compensateEventDefinition />
</bpmn:intermediateThrowEvent>
<bpmn:sequenceFlow id="f4" sourceRef="throw-comp" targetRef="end" />
<bpmn:endEvent id="end" />
</bpmn:process>
When throw-comp fires, the engine runs cancel-flight then cancel-hotel (reverse order), waits for both, and only then advances to end. Each handler sees the variables its host task produced - cancel-hotel sees the hotel booking reference, cancel-flight the flight reference.
A common refinement is to trigger compensation from an error path instead of the happy 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.
Not supported
| Pattern | Use instead |
|---|---|
| Compensation start event (in an event sub-process) | Catch the failure with an error/other event sub-process or boundary, then throw compensation from there |
| Compensation end event | A compensation intermediate throw event followed by a none end event |
A call activity also cannot itself be a compensation handler - isForCompensation="true" on a call activity is rejected at deployment. Only tasks and embedded sub-processes are valid handlers.
Rules and gotchas
- A compensation boundary event must have an associated handler activity (linked by
<association>), and the handler must setisForCompensation="true". - The handler has no sequence flows - it is reachable only through compensation.
cancelActivityis ignored on compensation boundaries, don't rely on it.- Compensation is synchronous: the throw waits for its handlers before advancing.
- Order is reverse completion order within a scope.
- Handlers run on snapshot data (host variables at completion time), not current process state.
Validation
| Check | Severity |
|---|---|
| Compensation boundary has no associated handler activity | Error |
Handler activity is not marked isForCompensation="true" | Error |
Compensation throw activityRef does not resolve to a known node | Error |
| Compensation start event used anywhere | Error (not supported) |
| Compensation end event used anywhere | Error (not supported) |
isForCompensation="true" set on a call activity | Error (only tasks and embedded sub-processes can be handlers) |
See also: Events → Compensation throw and boundary and Call activity → Compensation.