Getting Started with Ranvier

Version: 0.17 Updated: 2026-03-04


Prerequisites


1. Your First Axon (5 min)

Ranvier models workflows as typed pipelines. Each step is a Transition and they chain together into an Axon.

Create a project

cargo new my-ranvier-app
cd my-ranvier-app
cargo add ranvier-core ranvier-runtime tokio --features tokio/full
cargo add anyhow async-trait serde --features serde/derive
cargo add serde_json

Write your first transition

use async_trait::async_trait;
use ranvier_core::prelude::*;
use ranvier_runtime::Axon;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Greeting {
    name: String,
    message: String,
}

#[derive(Clone)]
struct BuildGreeting;

#[async_trait]
impl Transition<String, Greeting> for BuildGreeting {
    type Error = String;
    type Resources = ();

    async fn run(
        &self,
        name: String,
        _resources: &Self::Resources,
        _bus: &mut Bus,
    ) -> Outcome<Greeting, Self::Error> {
        Outcome::Next(Greeting {
            name: name.clone(),
            message: format!("Hello, {}!", name),
        })
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let axon = Axon::<String, String, String>::new("greeting")
        .then(BuildGreeting);

    let mut bus = Bus::new();
    let result = axon.execute("World".to_string(), &(), &mut bus).await;

    match result {
        Outcome::Next(greeting) => println!("{}", greeting.message),
        Outcome::Fault(e) => eprintln!("Error: {}", e),
        _ => {}
    }

    Ok(())
}

Key concepts

Concept Description
Transition<From, To> A single processing step: takes From, produces To
Axon A pipeline of chained transitions
Outcome<T, E> Result type: Next(T), Fault(E), Branch, Jump, Emit
Bus Type-indexed capability store, passed through the pipeline
Resources Shared dependencies injected into transitions

Run it

cargo run
# Output: Hello, World!

Try the example: cargo run -p hello-world (from the ranvier workspace)


2. Chaining Transitions (5 min)

Transitions compose with .then():

#[derive(Clone)]
struct ValidateInput;

#[async_trait]
impl Transition<String, String> for ValidateInput {
    type Error = String;
    type Resources = ();

    async fn run(
        &self,
        input: String,
        _resources: &(),
        _bus: &mut Bus,
    ) -> Outcome<String, Self::Error> {
        if input.is_empty() {
            return Outcome::Fault("input cannot be empty".to_string());
        }
        Outcome::Next(input)
    }
}

let axon = Axon::<String, String, String>::new("pipeline")
    .then(ValidateInput)
    .then(BuildGreeting);

Each .then() appends a step. The output type of one step must match the input type of the next. The compiler enforces this at build time.

Try the example: cargo run -p typed-state-tree


3. Testing Transitions (10 min)

Transitions are plain async functions — test them directly:

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

    #[tokio::test]
    async fn test_validate_rejects_empty() {
        let validator = ValidateInput;
        let mut bus = Bus::new();
        let result = validator.run("".to_string(), &(), &mut bus).await;
        assert!(result.is_fault());
    }

    #[tokio::test]
    async fn test_full_pipeline() {
        let axon = Axon::<String, String, String>::new("test")
            .then(ValidateInput)
            .then(BuildGreeting);

        let mut bus = Bus::new();
        let result = axon.execute("Alice".to_string(), &(), &mut bus).await;
        match result {
            Outcome::Next(g) => assert_eq!(g.message, "Hello, Alice!"),
            other => panic!("Expected Next, got: {:?}", other),
        }
    }
}

Try the example: cargo run -p testing-patterns (includes 7 unit + integration tests)


4. Custom Error Types (10 min)

Replace String errors with structured types using thiserror:

cargo add thiserror
use thiserror::Error;

#[derive(Debug, Clone, Error, Serialize, Deserialize)]
enum AppError {
    #[error("not found: {0}")]
    NotFound(String),
    #[error("validation failed: {0}")]
    Validation(String),
    #[error("unauthorized: {0}")]
    Unauthorized(String),
}

#[async_trait]
impl Transition<String, User> for FetchUser {
    type Error = AppError;
    type Resources = ();

    async fn run(
        &self,
        user_id: String,
        _resources: &(),
        _bus: &mut Bus,
    ) -> Outcome<User, Self::Error> {
        if user_id == "unknown" {
            return Outcome::Fault(AppError::NotFound(user_id));
        }
        Outcome::Next(User { id: user_id, name: "Alice".into() })
    }
}

Ranvier also provides RanvierError in the core prelude — a serde-compatible error type with Message, NotFound, Validation, and Internal variants.

Try the example: cargo run -p custom-error-types


5. Using the Bus (5 min)

The Bus is a type-indexed store for passing capabilities through the pipeline:

use ranvier_core::prelude::*;

// Define a capability
#[derive(Clone, Debug)]
struct RequestId(String);

// Insert before execution
let mut bus = Bus::new();
bus.insert(RequestId("req-123".to_string()));

// Read inside a transition
async fn run(&self, input: Input, _res: &(), bus: &mut Bus) -> Outcome<Output, Error> {
    let req_id = bus.read::<RequestId>();
    match req_id {
        Some(id) => println!("Processing request: {}", id.0),
        None => println!("No request ID"),
    }
    Outcome::Next(output)
}

The Bus supports insert(), read(), read_mut(), has(), and remove(). Types must be Any + Send + Sync + 'static.

Try the example: cargo run -p bus-capability-demo


6. Shared Resources (5 min)

For dependencies shared across all transitions (database pools, config), use Resources:

use ranvier_core::transition::ResourceRequirement;

#[derive(Clone)]
struct AppConfig {
    api_key: String,
    max_retries: u32,
}

impl ResourceRequirement for AppConfig {}

#[async_trait]
impl Transition<Request, Response> for CallExternalApi {
    type Error = String;
    type Resources = AppConfig;

    async fn run(
        &self,
        req: Request,
        config: &Self::Resources,
        _bus: &mut Bus,
    ) -> Outcome<Response, Self::Error> {
        println!("Using API key: {}", config.api_key);
        // ... use config.max_retries for retry logic
        Outcome::Next(response)
    }
}

// Pass resources at execution time
let config = AppConfig { api_key: "key".into(), max_retries: 3 };
let result = axon.execute(input, &config, &mut bus).await;

Resources must implement ResourceRequirement (a marker trait). The unit type () implements it by default.


7. Resilience: Retry & DLQ (10 min)

Ranvier provides built-in retry with dead-letter queue (DLQ) support:

use ranvier_core::prelude::*;
use ranvier_runtime::Axon;

let axon = Axon::<Input, Input, String>::new("resilient")
    .then(UnreliableStep)
    .with_dlq_policy(DlqPolicy::RetryThenDlq {
        max_attempts: 3,
        backoff_ms: 100,
    })
    .with_dlq_sink(MyDlqSink::new());

After max_attempts retries with exponential backoff, failed events are sent to the DLQ sink. Check the Bus for Timeline events to see retry/DLQ activity.

Try the example: cargo run -p retry-dlq-demo


8. Persistence: Checkpoint & Resume (10 min)

For long-running workflows, use the persistence layer to checkpoint state and resume from failures:

use ranvier_runtime::{
    InMemoryPersistenceStore, PersistenceHandle,
    PersistenceTraceId, PersistenceAutoComplete,
};
use std::sync::Arc;

let store = Arc::new(InMemoryPersistenceStore::new());
let handle = PersistenceHandle::from_arc(store.clone() as Arc<dyn PersistenceStore>);

// First run — auto_complete=false keeps trace open on fault
let mut bus = ranvier_core::ranvier_bus!(
    handle.clone(),
    PersistenceTraceId::new("order-123"),
    PersistenceAutoComplete(false),
);
let result = axon.execute(input, &(), &mut bus).await;

// Resume from last checkpoint
let trace = store.load("order-123").await?.unwrap();
let cursor = store.resume("order-123", trace.events.last().map(|e| e.step).unwrap_or(0)).await?;

Try the example: cargo run -p state-persistence-demo


9. What to Explore Next

By difficulty

Level Examples Time
Beginner hello-world, typed-state-tree, bus-capability-demo 5 min each
Intermediate routing-demo, flat-api-demo, testing-patterns, custom-error-types 10-15 min each
Advanced order-processing-demo, retry-dlq-demo, state-persistence-demo, multitenancy-demo 15-20 min each

By topic

Topic Examples
HTTP & Routing routing-demo, routing-params-demo, flat-api-demo, session-demo
Auth & Security auth-jwt-role-demo, guard-demo
Data & Persistence db-example, persistence-production-demo, ecosystem-redis-demo
Observability observe-http-demo, otel-demo, otel-concept-demo
Resilience retry-dlq-demo, state-persistence-demo
Enterprise synapse-demo, job-scheduler-demo, multitenancy-demo

Learning paths

  1. Quick Start: hello-world → typed-state-tree → testing-patterns → custom-error-types → routing-demo
  2. HTTP Services: routing-params → flat-api → session → multipart-upload → websocket → sse-streaming → openapi → auth-jwt-role
  3. Advanced Patterns: order-processing → retry-dlq → state-persistence → multitenancy

Run any example

# From the ranvier workspace root
cargo run -p <example-name>

Reference