Compensation (Saga)
Automatically undo completed work when a workflow fails, the saga way.
When a workflow fails partway through, the actions that already succeeded may have left side effects — a charge, a reservation, a created record. Compensation is Skipper’s mechanism for automatically undoing that work, giving you the saga pattern with almost no boilerplate.
How it works
When a workflow hits a non-retryable error (directly, or after retries are exhausted), Skipper automatically:
- Identifies every successfully executed action that has a compensation method.
- Runs those compensations in reverse order of execution.
- Moves the workflow to
COMPENSATION_COMPLETED. - Notifies any registered callback handler.
Executed: [charge] → [reserve] → [ship ✗ fails]
Compensated: [un-reserve] → [refund]
Writing compensation methods
Annotate a method with @Compensate(forExecute = "..."), naming the action it undoes.
class PaymentActions : Actions() {
@Execute
suspend fun charge(request: ChargeRequest): String =
paymentService.charge(request)
@Compensate(forExecute = "charge")
suspend fun refund(request: ChargeRequest, paymentId: String) {
// First param: the original action input.
// Second param (optional): the action's result.
paymentService.refund(paymentId)
}
}
public class PaymentActions extends Actions {
@Execute
public String charge(ChargeRequest request) {
return paymentService.charge(request);
}
@Compensate(forExecute = "charge")
public void refund(ChargeRequest request, String paymentId) {
// First param: the original action input.
// Second param (optional): the action's result.
paymentService.refund(paymentId);
}
}
Rules:
- A compensable action takes at most one argument, and it must be serializable.
- The compensation’s first parameter matches the action’s input type.
- An optional second parameter matches the action’s return type (the unwrapped value —
not a
CompletableFuture). These rules are validated when the workflow is created.
Compensation is automatic
You don’t call compensation yourself — Skipper runs it when the workflow fails. The workflow body stays clean:
@WorkflowMethod
suspend fun processOrder(req: OrderRequest) {
payments.charge(req.charge) // compensated if a later step fails
inventory.reserve(req.items) // compensated if a later step fails
shipping.schedule(req) // if this fails, the two above are undone
}
@WorkflowMethod
public CompletableFuture<Void> processOrder(OrderRequest req) {
payments.charge(req.getCharge()); // compensated if a later step fails
inventory.reserve(req.getItems()); // compensated if a later step fails
shipping.schedule(req); // if this fails, the two above are undone
return CompletableFuture.completedFuture(null);
}
Reacting to compensation
Implement callback methods to be notified when compensation finishes or itself fails (which may need manual intervention):
override fun onCompensationCompleted(workflow: WorkflowInstanceView) { /* ... */ }
override fun onCompensationError(workflow: WorkflowInstanceView, error: SkipperError) { /* ... */ }
@Override
public void onCompensationCompleted(WorkflowInstanceView workflow) { /* ... */ }
@Override
public void onCompensationError(WorkflowInstanceView workflow, SkipperError error) { /* ... */ }
This is how Skipper workflows achieve eventual data consistency across services.