Docs
rustavel is a Cargo workspace: each concept below lives in its own crate, imported together through rustavel_core::prelude::*.
Routing & controllers
Tag an impl block with #[controller] and its methods with a verb attribute; routes() is generated for you.
pub struct UserController;
#[controller]
impl UserController {
#[get("/users")]
pub async fn index(State(db): State<Db>) -> Result<Json<Vec<User>>, AppError> {
Ok(Json(User::all(&db).await?))
}
#[post("/users")]
pub async fn store(State(db): State<Db>, Json(payload): Json<NewUser>)
-> Result<(StatusCode, Json<User>), AppError>
{
Ok((StatusCode::CREATED, Json(User::create(&db, payload).await?)))
}
}
Merge controllers into your app's router:
pub fn api_routes() -> Router<AppState> {
Router::new().merge(UserController::routes())
}
Handler return types aren't constrained by the macros at all — anything implementing Axum's IntoResponse works, including AskamaHtml (see Views).
ORM (Active Record)
#[derive(Model)] generates find, all, create, save, and delete from a struct's shape.
#[derive(Model, sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
#[model(table = "users", timestamps)]
pub struct User {
#[model(primary_key, skip_on_insert)]
pub id: i64,
pub name: String,
pub email: String,
#[model(skip_on_insert)]
pub created_at: chrono::NaiveDateTime,
#[model(skip_on_insert)]
pub updated_at: chrono::NaiveDateTime,
}
#[model(primary_key)] marks the key column; #[model(skip_on_insert)] excludes DB-defaulted columns from the generated New{Struct} insertable type. Migrations are plain SQL, two files per migration ({timestamp}_{name}.up.sql / .down.sql), run via rustavel migrate.
Event sourcing
Opt in per model with #[derive(Aggregate)] when you want a full, replayable history instead of overwritten rows.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PostEvent {
Created { title: String, body: String },
Edited { title: String, body: String },
}
#[derive(Debug, Default, Aggregate)]
#[aggregate(stream = "post", event = "PostEvent")]
pub struct Post {
pub title: String,
pub body: String,
}
impl Post {
fn apply_event(&mut self, event: &PostEvent) {
match event {
PostEvent::Created { title, body } | PostEvent::Edited { title, body } => {
self.title = title.clone();
self.body = body.clone();
}
}
}
}
Events append to a shared events table with a UNIQUE(aggregate_type, aggregate_id, version) constraint — the optimistic-concurrency check. A racing writer gets OrmError::Concurrency (a 409), not a silent overwrite.
// Append + read
event_store::append::<Post, _>(&db, id, expected_version, &event).await?;
let post: Post = event_store::load_aggregate(&db, id).await?;
// Append + update a read-model table in the same transaction
projection::append_and_project::<Post, _>(&db, id, expected_version, &event, &projector).await?;
Projections implement Projector<A> and run synchronously, inside the same transaction as the append — no async worker, no eventual-consistency window.
Snapshots
Replaying a stream from event #1 gets slower as it grows, so load_aggregate transparently uses a cached snapshot when one exists and only replays the tail since it. Snapshots are on by default — every 100 events — and live in their own snapshots table, auto-generated (once, idempotently) by make:aggregate. They're never rows in events: a snapshot isn't a real domain event, and mixing the two would collide with the UNIQUE(aggregate_type, aggregate_id, version) concurrency check and break the invariant that every row in events is foldable via apply. A snapshot is a disposable read-side cache, not a second source of truth — wiping the table just means the next read replays from scratch.
#[derive(Debug, Default, Serialize, Deserialize, Aggregate)]
#[aggregate(stream = "post", event = "PostEvent", snapshot_every = 50)]
pub struct Post { /* ... */ }
// snapshot_every = 0 opts an aggregate out of snapshotting entirely.
Snapshot writes happen just after the triggering append's transaction commits, in a transaction of their own — an accepted tradeoff, since a snapshot is disposable: if the process dies in between, the next read just replays a few extra events, never a correctness issue.
Views (htmx + Askama)
Wrap any Askama template in AskamaHtml and return it from a handler:
#[derive(Template)]
#[template(path = "posts/show.html")]
struct PostShowTemplate { title: String, body: String }
#[get("/posts/{id}")]
pub async fn show(/* ... */) -> Result<AskamaHtml<PostShowTemplate>, AppError> {
/* ... */
}
Templates live in resources/views/. For htmx partial-swap responses, just return a smaller template from a different handler — the response body is the swap target's new contents:
<form hx-post="/posts/{{ id }}/comments" hx-target="#comments" hx-swap="beforeend">
<textarea name="body"></textarea>
<button type="submit">Add comment</button>
</form>
No SPA, no API client, no build step — the handler that creates the comment just renders the one-comment partial and returns it.
Authentication
rustavel-auth adds session-based auth: Argon2 password hashing, a DB-backed sessions table, and an AuthUser extractor that 401s automatically when a handler requires it.
impl rustavel_auth::Authenticatable for User {
fn user_id(&self) -> i64 { self.id }
}
#[get("/me")]
pub async fn me(auth: AuthUser, State(db): State<Db>) -> Result<Json<User>, AppError> {
Ok(Json(User::find(&db, auth.user_id).await?.ok_or(AppError::Unauthorized)?))
}
Background jobs
rustavel-queue adds a Laravel-style queue: dispatch() inserts a row into a jobs table, and rustavel queue:work runs a worker that polls, executes, and retries with backoff — a single SQLite-polling process, no Redis or external broker.
#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = "default", retries = 3)]
pub struct SendCommentNotification {
pub post_id: String,
pub comment_id: i64,
}
impl SendCommentNotification {
async fn handle_job(&self, db: &Db) -> Result<(), QueueError> {
sqlx::query("INSERT INTO notifications (post_id, comment_id) VALUES (?, ?)")
.bind(&self.post_id)
.bind(self.comment_id)
.execute(db)
.await?;
Ok(())
}
}
#[derive(Job)] generates NAME (the struct's own name), QUEUE, and MAX_ATTEMPTS; the real work goes in a hand-written handle_job, the same split as #[derive(Aggregate)]'s apply_event.
rustavel_queue::dispatch(&db, &SendCommentNotification { post_id, comment_id }).await?;
Claiming a job is a single atomic UPDATE ... WHERE id = (SELECT ...) RETURNING, so two workers racing on the same row can't both claim it. A job that returns Err is retried with exponential backoff until it hits its MAX_ATTEMPTS, at which point it moves to failed_jobs instead of retrying forever. Since Rust has no runtime reflection to go from a job's stored name back to its type, an app registers every job it dispatches with a JobRegistry before running the worker:
let mut registry = JobRegistry::new();
registry.register::<SendCommentNotification>();
rustavel_queue::worker::run(&db, ®istry, WorkOptions::default()).await?;
make:job lays down the jobs/failed_jobs migration once, idempotently — the same pattern make:aggregate uses for the snapshots table.
CLI reference
rustavel new <name> [--local] | Scaffold a new app |
rustavel make:controller <Name> | Generate a controller |
rustavel make:model <Name> [--migration] | Generate a model |
rustavel make:aggregate <Name> | Generate an event-sourced aggregate (also lays down the snapshots migration, once) |
rustavel make:projection <Name> --aggregate <Agg> | Generate a read-model projector |
rustavel make:job <Name> | Generate a queueable job (also lays down the jobs/failed_jobs migration, once) |
rustavel make:view <path> | Generate an Askama view |
rustavel make:migration <name> [--create <table>] | Generate a migration pair |
rustavel migrate / migrate:rollback / migrate:fresh | Run / undo migrations |
rustavel serve | Run the dev server |
rustavel queue:work [--queue <name>] [--once] | Run the queue worker |
Ready to build something? Head to the quickstart.