#Tutorial: Building a Payment Pipeline with Ranvier

Version: 0.43.0 | Updated: 2026-03-28 | Category: Tutorial

End-to-end payment pipeline with saga compensation, branching, and schematic inspection.

Reading time: 20 min | Practice time: 40 min


#Overview

Build a production-style payment gateway: validate orders, detect fraud, route payments by method, settle with a provider -- with automatic saga rollback on failure.


#Step 1: Create the Project

ranvier new payment-gateway --template saga
cd payment-gateway

Generated structure: src/main.rs, src/transitions/mod.rs, Cargo.toml.

[dependencies]
ranvier = "0.43"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[dev-dependencies]
ranvier-test = "0.43"

#Step 2: Define the Transitions

Four transitions in src/transitions/mod.rs, each using #[transition] and Outcome<T, String>.

use ranvier::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OrderRequest {
    pub order_id: String,
    pub customer_id: String,
    pub amount_cents: u64,
    pub payment_method: String, // "card", "bank_transfer", "wallet"
    pub currency: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PaymentResult {
    pub order_id: String,
    pub provider_ref: String,
    pub settled: bool,
}

#[derive(Clone, Debug)]
struct FraudScore(u32);
#[derive(Clone, Debug)]
struct RoutedProvider(String);

#validate_order

#[transition]
async fn validate_order(
    order: OrderRequest, _resources: &(), _bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    if order.order_id.is_empty() {
        return Outcome::Fault("order_id is required".into());
    }
    if order.amount_cents == 0 {
        return Outcome::Fault("amount must be greater than zero".into());
    }
    if order.currency.len() != 3 {
        return Outcome::Fault("currency must be a 3-letter ISO code".into());
    }
    tracing::info!(order_id = %order.order_id, "order validated");
    Outcome::Next(order)
}

#detect_fraud

#[transition]
async fn detect_fraud(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    let score = if order.amount_cents > 100_000 { 80 } else { 10 };
    bus.insert(FraudScore(score));
    if score >= 90 {
        return Outcome::Fault(format!("order {} flagged (score={})", order.order_id, score));
    }
    tracing::info!(order_id = %order.order_id, score, "fraud check passed");
    Outcome::Next(order)
}

#route_payment

#[transition]
async fn route_payment(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    let provider = match order.payment_method.as_str() {
        "card" => "stripe",
        "bank_transfer" => "plaid",
        "wallet" => "internal-ledger",
        other => return Outcome::Fault(format!("unsupported method: {}", other)),
    };
    bus.insert(RoutedProvider(provider.to_string()));
    tracing::info!(order_id = %order.order_id, provider, "payment routed");
    Outcome::Next(order)
}

#settle_payment

#[transition]
async fn settle_payment(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<PaymentResult, String> {
    let provider = bus.get_cloned::<RoutedProvider>()
        .map(|p| p.0).unwrap_or_else(|_| "unknown".into());
    let provider_ref = format!("{}-{}", provider, uuid::Uuid::new_v4());
    tracing::info!(order_id = %order.order_id, %provider_ref, "settled");
    Outcome::Next(PaymentResult {
        order_id: order.order_id, provider_ref, settled: true,
    })
}

#Step 3: Add Branch Logic

When each payment method needs a separate sub-pipeline, use Outcome::Branch:

#[transition]
async fn route_payment_branching(
    order: OrderRequest, _resources: &(), _bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    match order.payment_method.as_str() {
        "card" => Outcome::Branch {
            label: "card-processor".into(),
            payload: serde_json::to_value(&order).unwrap(),
        },
        "bank_transfer" => Outcome::Branch {
            label: "bank-processor".into(),
            payload: serde_json::to_value(&order).unwrap(),
        },
        other => Outcome::Fault(format!("unsupported method: {}", other)),
    }
}

The Branch label routes execution to a registered handler. The simpler route_payment in Step 2 uses a match-and-continue pattern when the downstream pipeline is shared across methods.


#Step 4: Add Saga Compensation

Pair each side-effecting step with a compensation. On failure, compensations run in LIFO order.

#[derive(Clone, Debug)]
struct AuthorizationId(String);
#[derive(Clone, Debug)]
struct CaptureId(String);

#[transition]
async fn authorize_payment(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    let auth_id = format!("auth-{}", order.order_id);
    bus.insert(AuthorizationId(auth_id.clone()));
    tracing::info!(order_id = %order.order_id, %auth_id, "authorized");
    Outcome::Next(order)
}

#[transition]
async fn reverse_authorization(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    let auth_id = bus.get_cloned::<AuthorizationId>()
        .map(|a| a.0).unwrap_or_else(|_| "unknown".into());
    tracing::warn!(%auth_id, "COMPENSATION: reversed authorization");
    Outcome::Next(order)
}

#[transition]
async fn capture_payment(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    let capture_id = format!("cap-{}", order.order_id);
    bus.insert(CaptureId(capture_id.clone()));
    tracing::info!(order_id = %order.order_id, %capture_id, "captured");
    Outcome::Next(order)
}

#[transition]
async fn refund_capture(
    order: OrderRequest, _resources: &(), bus: &mut Bus,
) -> Outcome<OrderRequest, String> {
    let capture_id = bus.get_cloned::<CaptureId>()
        .map(|c| c.0).unwrap_or_else(|_| "unknown".into());
    tracing::warn!(%capture_id, "COMPENSATION: refunded capture");
    Outcome::Next(order)
}

#Build the saga pipeline

fn build_payment_saga() -> impl Execute<OrderRequest, PaymentResult, String, ()> {
    Axon::typed::<OrderRequest, String>("payment-saga")
        .with_saga_policy(SagaPolicy::Enabled)
        .then(validate_order)
        .then(detect_fraud)
        .then_compensated(authorize_payment, reverse_authorization)
        .then_compensated(capture_payment, refund_capture)
        .then(settle_payment)
}

If settle_payment faults: refund_capture runs first, then reverse_authorization (LIFO). validate_order and detect_fraud are read-only -- no compensation needed.


#Step 5: Schematic Export and Diff

ranvier schematic payment-saga --output schematic.json

The JSON contains nodes (transitions with name, index, type metadata), edges (forward + compensation connections), saga_policy, and metadata.

Diff across git refs to catch pipeline changes in code review:

git show main:schematic.json > /tmp/old.json
ranvier schematic payment-saga --output /tmp/new.json
diff /tmp/old.json /tmp/new.json

#Step 6: Inspector Integration

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt::init();
    let saga = build_payment_saga();

    Ranvier::http()
        .bind("127.0.0.1:3000")
        .post_typed_json_out::<OrderRequest>("/api/payment", saga)
        .inspector(9090)
        .run(())
        .await?;
    Ok(())
}

Open http://localhost:9090 to see the pipeline graph, live executions, Bus snapshots at each step, and a timeline of outcomes and compensations. Test with:

curl -X POST http://localhost:3000/api/payment \
  -H "Content-Type: application/json" \
  -d '{"order_id":"ORD-001","customer_id":"C-42","amount_cents":5000,"payment_method":"card","currency":"USD"}'

#Step 7: Testing with ranvier-test

#[cfg(test)]
mod tests {
    use super::*;
    use ranvier_core::prelude::*;

    #[tokio::test]
    async fn test_successful_payment() {
        let saga = build_payment_saga();
        let mut bus = Bus::new();
        let order = OrderRequest {
            order_id: "ORD-TEST-001".into(), customer_id: "C-1".into(),
            amount_cents: 5000, payment_method: "card".into(), currency: "USD".into(),
        };
        let result = saga.execute(order, &(), &mut bus).await;
        assert!(matches!(result, Outcome::Next(_)));
        if let Outcome::Next(ref payment) = result {
            assert_eq!(payment.order_id, "ORD-TEST-001");
            assert!(payment.settled);
        }
    }

    #[tokio::test]
    async fn test_validation_rejects_zero_amount() {
        let saga = build_payment_saga();
        let mut bus = Bus::new();
        let order = OrderRequest {
            order_id: "ORD-BAD".into(), customer_id: "C-1".into(),
            amount_cents: 0, payment_method: "card".into(), currency: "USD".into(),
        };
        let result = saga.execute(order, &(), &mut bus).await;
        assert!(matches!(result, Outcome::Fault(_)));
    }

    #[tokio::test]
    async fn test_compensation_on_failure() {
        let saga = Axon::typed::<OrderRequest, String>("compensation-test")
            .with_saga_policy(SagaPolicy::Enabled)
            .then(validate_order)
            .then_compensated(authorize_payment, reverse_authorization)
            .then_compensated(capture_payment, refund_capture)
            .then(always_fault);
        let mut bus = Bus::new();
        let order = OrderRequest {
            order_id: "ORD-COMP".into(), customer_id: "C-1".into(),
            amount_cents: 5000, payment_method: "card".into(), currency: "USD".into(),
        };
        let result = saga.execute(order, &(), &mut bus).await;
        assert!(matches!(result, Outcome::Fault(_)));
        // Compensations (refund_capture, reverse_authorization) ran in LIFO order
    }

    #[transition]
    async fn always_fault(
        _order: OrderRequest, _resources: &(), _bus: &mut Bus,
    ) -> Outcome<PaymentResult, String> {
        Outcome::Fault("simulated settlement failure".into())
    }
}
cargo test

#What's Next

  • Saga Compensation Cookbook -- advanced compensation patterns including retry, persistence, and failure handling
  • Axum Integration -- run Ranvier pipelines alongside Axum handlers in a hybrid server
  • Examples Explorer -- browse all 62 runnable examples

  • Getting Started Guide -- foundational concepts and your first Axon
  • Outcome Patterns Cookbook -- try_outcome!, combinators, and from_result
  • Bus Access Patterns -- choosing the right Bus method
  • Pattern Catalog -- 12 reusable architecture patterns