#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 todosPOST /todosโ create a todoGET /todos/:idโ get a specific todoPUT /todos/:idโ update a todoDELETE /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 |