Saga: Undoing What Cannot Be Undone

What You'll Learn

  • How sagas coordinate multi-step operations with compensating transactions
  • How to build sagas with Saga.of() and SagaBuilder
  • How compensation executes in reverse order on failure
  • The distinction between Saga and Resource
  • How to handle compensation failures

Some operations span multiple services. An e-commerce order might charge a payment, reserve inventory, and schedule shipping. Each step succeeds independently, but the business transaction only succeeds if all three complete. If shipping fails after payment and inventory have succeeded, you need to release the inventory and refund the payment, in that order.

This is the saga pattern: each forward step registers a corresponding compensation action. On failure, compensations execute in reverse order to restore the system to a consistent state.

Saga vs Resource

Both manage cleanup, but for different purposes:

SagaResource
CleanupBusiness logic (refund, release, cancel)Infrastructure (close file, release connection)
OrderReverse order of completionLIFO stack
Depends onWhat the forward step producedFixed cleanup action
ScopeDistributed transactionsSingle resource lifecycle

Use Resource for files, connections, and locks. Use Saga for multi-step business workflows where each step's undo depends on what that step accomplished.

The Flow

flowchart LR
    C["Charge Payment<br/>result: pay-123"] --> R["Reserve Stock<br/>result: res-456"] --> S["Schedule Shipping"]
    S -->|"fails: compensate completed<br/>steps, newest first"| U1["1. Release res-456"]
    U1 --> U2["2. Refund pay-123"]

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef error fill:#e78284,stroke:#d20f39,color:#232634
    classDef comp fill:#8caaee,stroke:#1e66f5,color:#232634
    class C,R tier
    class S error
    class U1,U2 comp

Key points:

  • Shipping failed, so its compensation does not run (nothing to undo)
  • Stock was reserved successfully, so its compensation releases the reservation
  • Payment was charged successfully, so its compensation issues a refund
  • Compensations run in reverse order: stock first, then payment

Creating a Saga

Direct Construction

Saga<String> orderSaga = Saga.of(
        VTask.of(() -> paymentService.charge(order)),
        paymentId -> paymentService.refund(paymentId))
    .andThen(paymentId -> Saga.of(
        VTask.of(() -> inventoryService.reserve(order)),
        reservationId -> inventoryService.release(reservationId)))
    .andThen(reservationId -> Saga.of(
        VTask.of(() -> shippingService.schedule(order)),
        trackingId -> shippingService.cancel(trackingId)));

Using SagaBuilder

For larger sagas, the builder provides a more readable structure:

Saga<String> orderSaga = SagaBuilder.<Unit>start()
    .step("charge-payment",
        VTask.of(() -> paymentService.charge(order)),
        paymentId -> paymentService.refund(paymentId))
    .step("reserve-inventory",
        paymentId -> VTask.of(() -> inventoryService.reserve(order, paymentId)),
        reservationId -> inventoryService.release(reservationId))
    .step("schedule-shipping",
        reservationId -> VTask.of(() -> shippingService.schedule(order, reservationId)),
        trackingId -> shippingService.cancel(trackingId))
    .build();

Step names appear in error reporting, making it clear which step failed and which compensations ran.

Async Compensation

When compensation itself requires an asynchronous operation, use stepAsync:

SagaBuilder.<Unit>start()
    .stepAsync("charge-payment",
        _ -> VTask.of(() -> paymentService.charge(order)),
        paymentId -> VTask.of(() -> {
            paymentService.refund(paymentId);
            return Unit.INSTANCE;
        }))
    .build();

Steps Without Compensation

Some steps are idempotent or represent final actions that do not need undoing:

SagaBuilder.<Unit>start()
    .step("charge-payment",
        VTask.of(() -> paymentService.charge(order)),
        paymentId -> paymentService.refund(paymentId))
    .stepNoCompensation("send-confirmation",
        paymentId -> VTask.of(() -> emailService.sendConfirmation(order, paymentId)))
    .build();

Running a Saga

run(): Throws on Failure

VTask<String> execution = orderSaga.run();

try {
    String trackingId = execution.run();
    log.info("Order complete: {}", trackingId);
} catch (SagaExecutionException e) {
    // a compensation also failed; e carries the full SagaError
    log.error("Order failed and compensation was incomplete: {}", e.getMessage());
} catch (RuntimeException original) {
    // all compensations succeeded; the step's failure surfaces directly
    // (a checked exception arrives wrapped in VTaskExecutionException)
    log.error("Order failed, fully compensated: {}", original.getMessage());
}

If all compensations succeed, the original exception is thrown directly. If any compensation also fails, a SagaExecutionException is thrown containing the full SagaError.

The saga's own runSafe() (distinct from VTask.runSafe(), which returns a Try) keeps the failure structured instead of thrown:

runSafe(): Either with Full Details

VTask<Either<SagaError, String>> safeExecution = orderSaga.runSafe();

Either<SagaError, String> result = safeExecution.run();
result.fold(
    sagaError -> {
        log.error("Saga failed at step '{}': {}",
            sagaError.failedStep(),
            sagaError.originalError().getMessage());

        if (!sagaError.allCompensationsSucceeded()) {
            log.error("Compensation failures: {}",
                sagaError.compensationFailures());
        }
        return null;
    },
    trackingId -> {
        log.info("Order complete: {}", trackingId);
        return null;
    }
);

Handling Compensation Failures

Sometimes compensation itself fails (e.g., the refund service is down). The saga records all compensation results:

SagaError error = ...;

// Did all compensations succeed?
if (error.allCompensationsSucceeded()) {
    // System is consistent; handle the original error
} else {
    // System may be inconsistent; log for manual intervention
    for (SagaError.CompensationResult cr : error.compensationResults()) {
        cr.result().fold(
            failure -> {
                log.error("Compensation '{}' failed: {}", cr.stepName(), failure);
                alertOps(cr.stepName(), failure);
                return null;
            },
            success -> {
                log.info("Compensation '{}' succeeded", cr.stepName());
                return null;
            }
        );
    }
}

Compensation Is Best-Effort

All compensations are attempted even if some fail. The saga does not stop compensating on the first compensation failure. This maximises the chance of restoring consistency, but means that partial compensation is possible. Design your compensations to be idempotent where possible.

Saga Factory Methods

MethodDescription
Saga.of(action, consumer)Single step with synchronous compensation
Saga.of(action, function)Single step with async compensation (VTask)
Saga.noCompensation(action)Single step with no compensation
saga.andThen(fn)Chain another saga step
saga.map(fn)Transform the final result
saga.flatMap(fn)Chain with another saga

Key Takeaways

  • Each compensatable completed step registers an undo: compensations run in reverse order over the steps that finished; the failed step itself, a stepNoCompensation step, and anything a partially applied action did before failing have nothing registered to undo
  • Saga is for business cleanup, Resource for infrastructure: refunds and releases belong here; files, connections, and locks belong to Resource
  • runSafe() tells the whole story: SagaError names the failed step and carries every compensation result, so partial compensation is detectable, not silent
  • Compensation is best-effort: all compensations are attempted even when some fail; design them idempotent so a repeated undo is harmless

See Also


Previous: Bulkhead Next: Combined Patterns