#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-gatewayGenerated 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.jsonThe 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
#Related Documents
- Getting Started Guide -- foundational concepts and your first Axon
- Outcome Patterns Cookbook --
try_outcome!, combinators, andfrom_result - Bus Access Patterns -- choosing the right Bus method
- Pattern Catalog -- 12 reusable architecture patterns