#Saga Compensation Patterns โ€” Deep Dive

Version: 0.43.0 | Updated: 2026-03-28 | Applies to: ranvier-runtime 0.36+ | Category: Cookbook


#Overview

The Saga pattern is Ranvier's approach to managing distributed transactions across multiple steps. When step N fails, steps 1 through N-1 must be "compensated" (rolled back) in reverse order. This cookbook covers advanced saga patterns beyond the basics covered in cookbook_saga_compensation.md.

Prerequisites: cookbook_saga_compensation.md (basic saga patterns)


#1. Pattern Anatomy

Every saga has three components:

Forward Steps:          A โ†’ B โ†’ C โ†’ D
Compensation Steps:     A'โ† B'โ† C'
Control:                Outcome::Fault triggers compensation

Key principles:

  • LIFO ordering: Compensations run in reverse (C' โ†’ B' โ†’ A')
  • Idempotency: Each compensation must be safe to retry
  • Isolation: Each step operates on its own subset of state

#2. Applied Domain: Payment Order Processing

The canonical saga example โ€” reserve inventory, charge payment, confirm shipping.

use ranvier_runtime::Axon;
use ranvier_core::saga::SagaPolicy;

let order_saga = Axon::typed::<CreateOrderRequest, String>("order-saga")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(create_order)
    .then_compensated(reserve_inventory, release_inventory)
    .then_compensated(authorize_payment, refund_payment)
    .then_compensated(confirm_shipping, cancel_shipment)
    .then(complete_order);

#Why Saga here?

Each step has an external side effect (inventory reservation, payment charge, shipping label) that cannot be automatically rolled back by a database transaction. The saga pattern makes compensation explicit and auditable.


#3. Applied Domain: Insurance Claim Processing

Multi-step claim: validate policy โ†’ assess damage โ†’ approve payout โ†’ schedule disbursement.

let claim_saga = Axon::typed::<ClaimRequest, String>("claim-saga")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(validate_policy)
    .then_compensated(assess_damage, void_assessment)
    .then_compensated(approve_payout, revoke_approval)
    .then_compensated(schedule_disbursement, cancel_disbursement)
    .then(close_claim);

#Compensation nuances

  • void_assessment: Marks the assessment as voided, not deleted (audit trail)
  • revoke_approval: Sends notification to the claimant about the reversal
  • cancel_disbursement: Only possible if funds haven't left the account

#4. Applied Domain: Multi-Service Onboarding

User registration: create account โ†’ provision resources โ†’ send welcome email โ†’ activate.

let onboarding_saga = Axon::typed::<SignupRequest, String>("onboarding")
    .with_saga_policy(SagaPolicy::Enabled)
    .then(create_account)
    .then_compensated(provision_resources, deprovision_resources)
    .then_compensated(send_welcome_email, send_cancellation_email)
    .then(activate_account);

#Key insight

create_account has no compensation โ€” it's the first step. If it fails, no compensation is needed. The then() (not then_compensated()) distinction is intentional.


#5. Advanced: Conditional Compensation

Sometimes compensation logic depends on the failure context. Use the Bus to carry failure metadata into compensation transitions.

#[transition]
async fn authorize_payment(
    input: serde_json::Value,
    bus: &mut Bus,
) -> Outcome<serde_json::Value, String> {
    let result = payment_gateway.charge(input["amount"].as_f64().unwrap()).await;
    match result {
        Ok(tx_id) => {
            bus.insert(tx_id); // Store for compensation
            Outcome::Next(input)
        }
        Err(e) => Outcome::Fault(format!("Payment failed: {}", e)),
    }
}

#[transition]
async fn refund_payment(
    input: serde_json::Value,
    bus: &mut Bus,
) -> Outcome<serde_json::Value, String> {
    // Only refund if we have a transaction ID
    if let Ok(tx_id) = bus.get_cloned::<String>() {
        payment_gateway.refund(&tx_id).await.ok();
        tracing::warn!(tx_id = %tx_id, "Payment refunded");
    }
    Outcome::Next(input)
}

#6. Testing Saga Compensation

Use ranvier-test to verify compensation ordering:

#[tokio::test]
async fn compensation_runs_in_reverse() {
    let saga = Axon::typed::<OrderRequest, String>("test-saga")
        .with_saga_policy(SagaPolicy::Enabled)
        .then(step_a)
        .then_compensated(step_b, comp_b)
        .then_compensated(step_c_fails, comp_c)
        .then(step_d);

    let mut bus = Bus::new();
    let result = saga.execute(order, &(), &mut bus).await;

    // step_c_fails returns Fault โ†’ comp_c runs, then comp_b
    assert!(matches!(result, Outcome::Fault(_)));
}

#Quick Reference

Scenario Use Saga? Why
Multi-service writes Yes Each service needs explicit rollback
Single DB transaction No Use database ACID instead
Read-only pipeline No Nothing to compensate
Idempotent operations Maybe Saga adds clarity even if retry is safe

#See Also

  • cookbook_saga_compensation.md โ€” Basic saga patterns
  • saga-compensation example โ€” Pure Axon saga demo (no HTTP)
  • reference-ecommerce-order example โ€” Full HTTP saga with audit