Docs

rustavel is a Cargo workspace: each concept below lives in its own crate, imported together through rustavel_core::prelude::*.

Routing & controllers ORM (Active Record) Event sourcing Views (htmx + Askama) Authentication Background jobs CLI reference

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, &registry, 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:freshRun / undo migrations
rustavel serveRun the dev server
rustavel queue:work [--queue <name>] [--once]Run the queue worker

Ready to build something? Head to the quickstart.