#Tutorial: Build a TODO API (Step by Step)

Version: 0.33.0 Updated: 2026-03-15 Applies to: ranvier, ranvier-core, ranvier-runtime, ranvier-http, ranvier-std Category: Getting Started

Difficulty: Beginnerโ€“Intermediate Time: ~30 minutes Goal: Build a fully functional in-memory TODO REST API with Ranvier, step by step with checkpoint validations.


#What You'll Build

A REST API with:

  • GET /todos โ€” list all todos
  • POST /todos โ€” create a todo
  • GET /todos/:id โ€” get a specific todo
  • PUT /todos/:id โ€” update a todo
  • DELETE /todos/:id โ€” delete a todo

#Setup

ranvier new todo-api --template crud-api
cd todo-api
cargo check   # โœ… Checkpoint 0: project structure is valid

#Checkpoint 1: Define the data model (5 min)

Create src/model.rs:

use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo {
    pub id: u64,
    pub title: String,
    pub done: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTodo {
    pub title: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateTodo {
    pub title: Option<String>,
    pub done: Option<bool>,
}

/// Shared in-memory store โ€” passed as Ranvier Resources
#[derive(Clone)]
pub struct TodoStore {
    pub todos: Arc<Mutex<HashMap<u64, Todo>>>,
    pub next_id: Arc<Mutex<u64>>,
}

impl TodoStore {
    pub fn new() -> Self {
        Self {
            todos: Arc::new(Mutex::new(HashMap::new())),
            next_id: Arc::new(Mutex::new(1)),
        }
    }
}

impl ranvier_core::transition::ResourceRequirement for TodoStore {}

Add to src/main.rs:

mod model;
cargo check   # โœ… Checkpoint 1: data model compiles

#Checkpoint 2: Implement list and create (7 min)

Create src/handlers.rs:

use crate::model::{CreateTodo, Todo, TodoStore};
use ranvier_core::prelude::*;

#[derive(Clone)]
pub struct ListTodos;

#[async_trait::async_trait]
impl Transition<(), String> for ListTodos {
    type Error = anyhow::Error;
    type Resources = TodoStore;

    async fn run(&self, _: (), store: &TodoStore, _: &mut Bus) -> Outcome<String, anyhow::Error> {
        let todos = store.todos.lock().unwrap();
        let list: Vec<&Todo> = todos.values().collect();
        match serde_json::to_string(&list) {
            Ok(json) => Outcome::Next(json),
            Err(e) => Outcome::Fault(e.into()),
        }
    }
}

#[derive(Clone)]
pub struct CreateTodoHandler;

#[async_trait::async_trait]
impl Transition<(), String> for CreateTodoHandler {
    type Error = anyhow::Error;
    type Resources = TodoStore;

    async fn run(&self, _: (), store: &TodoStore, bus: &mut Bus) -> Outcome<String, anyhow::Error> {
        // Read parsed body from Bus (set by HTTP extractor layer)
        let body = bus.read::<CreateTodo>().cloned()
            .unwrap_or_else(|| CreateTodo { title: "Untitled".into() });

        let mut next_id = store.next_id.lock().unwrap();
        let id = *next_id;
        *next_id += 1;

        let todo = Todo { id, title: body.title, done: false };
        store.todos.lock().unwrap().insert(id, todo.clone());

        match serde_json::to_string(&todo) {
            Ok(json) => Outcome::Next(json),
            Err(e) => Outcome::Fault(e.into()),
        }
    }
}
cargo check   # โœ… Checkpoint 2: list and create handlers compile

#Checkpoint 3: Wire routes (5 min)

Update src/main.rs:

mod model;
mod handlers;

use handlers::{CreateTodoHandler, ListTodos};
use model::TodoStore;
use ranvier_core::prelude::*;
use ranvier_http::prelude::*;
use ranvier_runtime::Axon;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let store = TodoStore::new();

    let list_todos = Axon::<(), (), anyhow::Error, TodoStore>::new("list-todos")
        .then(ListTodos);
    let create_todo = Axon::<(), (), anyhow::Error, TodoStore>::new("create-todo")
        .then(CreateTodoHandler);

    println!("TODO API running on http://127.0.0.1:3000");

    Ranvier::http::<TodoStore>()
        .bind("127.0.0.1:3000")
        .route_group(
            RouteGroup::new("/todos")
                .get("", list_todos)
                .post("", create_todo),
        )
        .run(store)
        .await
        .map_err(|e| anyhow::anyhow!("{}", e))?;

    Ok(())
}
cargo run   # โœ… Checkpoint 3: server starts
curl http://127.0.0.1:3000/todos
# โ†’ []

#Checkpoint 4: Create a todo and verify (3 min)

# Create a todo
curl -X POST http://127.0.0.1:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Ranvier"}'
# โ†’ {"id":1,"title":"Learn Ranvier","done":false}

# List todos
curl http://127.0.0.1:3000/todos
# โ†’ [{"id":1,"title":"Learn Ranvier","done":false}]

โœ… Checkpoint 4 complete! Basic CRUD is working.


#What To Add Next

Feature Hint
GET /todos/:id Read :id from bus.read::<PathParams>()
PUT /todos/:id Read UpdateTodo from Bus + update store
DELETE /todos/:id Remove entry from HashMap
Persist to PostgreSQL See ranvier-db crate + ecosystem_reference_seaorm.md
Add observability See tutorial_observability.md

#Key Concepts Demonstrated

Concept Where
Typed Resources TodoStore passed as Axon resource
Shared state Arc<Mutex<HashMap>> in TodoStore
Transition per handler One struct per action, impl Transition
RouteGroup DSL .route_group(RouteGroup::new("/todos")...)
Bus as side channel bus.read::<CreateTodo>() for parsed body