modo (Latin: "way, method") — a Rust web framework for small monolithic apps.
One crate. Zero proc macros. Everything you need to ship a real app — sessions, auth, background jobs, email, storage — without stitching together 15 crates and writing the glue yourself.
Built on axum 0.8, so you keep full access to the axum/tower ecosystem. Handlers are plain async fn. Routes use axum's Router. Database queries use libsql directly. No magic, no code generation, no framework lock-in.
You need 15+ crates for a real Rust web app. Sessions, auth, background jobs, config, email, flash messages, rate limiting, CORS, CSRF — each one is a separate crate with its own patterns, its own wiring, and its own test setup. modo gives you all of it in one import.
Proc macros slow you down. They increase compile times, hide control flow, and make errors cryptic. modo uses zero proc macros. Handlers are plain functions. Routes are axum routes. What you see is what runs.
Wiring everything together is the real work. Config loading, service injection, middleware ordering, graceful shutdown — the framework should handle this, not you. With modo, it's one Registry, one run! macro, and you're done.
use modo::{Config, Result};
use modo::axum::{Router, routing::get};
async fn hello() -> &'static str {
"Hello, modo!"
}
#[tokio::main]
async fn main() -> Result<()> {
let config: Config = modo::config::load("config/")?;
let app = Router::new().route("/", get(hello));
let server = modo::server::http(app, &config.server).await?;
modo::run!(server).await
}YAML files with ${ENV_VAR} and ${ENV_VAR:default} substitution, loaded per APP_ENV. No builder, no manual env parsing, no .env ceremony.
# config/production.yaml
server:
port: ${PORT:8080}
database:
url: ${DATABASE_URL}let config: modo::Config = modo::config::load("config/")?;SQLite via libsql. A single Database handle wraps an Arc-ed connection — clone-friendly, no pool complexity.
let db = modo::db::connect(&config.database).await?;
modo::db::migrate(db.conn(), "migrations").await?;Database-backed, sliding expiry, multi-device, fingerprinting. Two transports
share one table and one Session data type — pick cookies for browsers, JWT for
API clients.
Cookie sessions — browser apps, same-site, CSRF-bound:
// Login: CookieSession mutates; Session reads.
async fn login(cookie: CookieSession, Json(form): Json<LoginForm>) -> Result<()> {
// ... validate credentials, get user_id ...
cookie.authenticate(&user_id).await
}
async fn dashboard(session: Session) -> Result<String> {
Ok(format!("Welcome, {}", session.user_id))
}JWT sessions — mobile apps, SPAs, API clients:
// Login: returns an access + refresh token pair.
async fn login(
State(svc): State<JwtSessionService>,
Json(form): Json<LoginForm>,
) -> Result<Json<TokenPair>> {
// ... validate credentials, get user_id and build meta ...
Ok(Json(svc.authenticate(&user_id, &meta).await?))
}
// Refresh: rotate issues a new pair and invalidates the old refresh token.
async fn refresh(jwt: JwtSession) -> Result<Json<TokenPair>> {
Ok(Json(jwt.rotate().await?))
}Password hashing (Argon2id), TOTP (Google Authenticator compatible), one-time codes, backup codes, JWT with middleware, OAuth2 (GitHub, Google) — all plain functions and types, no annotations.
let hash = modo::auth::password::hash(password, &PasswordConfig::default()).await?;
let valid = modo::auth::password::verify(password, &hash).await?;
let totp = Totp::from_base32(secret, &TotpConfig::default())?;
let ok = totp.verify(user_code);SQLite-backed queue with retries, exponential backoff, timeouts, scheduled execution, and idempotent enqueue. Handlers use the same extraction pattern as HTTP routes.
async fn send_email(Payload(p): Payload<Email>, Service(mailer): Service<Mailer>) -> Result<()> {
mailer.send(&p.to, &p.body).await
}
let worker = Worker::builder(&config.job, ®istry)
.register("send_email", send_email)
.start().await;
Enqueuer::new(db.clone()).enqueue("send_email", &payload).await?;The run! macro waits for SIGTERM/SIGINT, then shuts down each component in declaration order. No cancellation tokens, no orchestration code.
modo::run!(worker, server).awaitRegistry is a typed map. .add() at startup, Service<T> in handlers. No #[inject], no container config, no runtime reflection.
use modo::service::{Registry, Service};
let mut registry = Registry::new();
registry.add(db); // modo::db::Database
registry.add(mailer); // your mailer type
// In any handler:
async fn list_users(Service(db): Service<Database>) -> Result<Json<Vec<User>>> { /* ... */ }JsonRequest<T>, FormRequest<T>, and Query<T> call your Sanitize impl before the handler runs. Define it once, applied everywhere.
Rate limiting, CORS, CSRF, compression, security headers, request tracing, panic catching, error handler — all included with sensible defaults. All standard Tower layers, not a custom system.
| Module | What it does |
|---|---|
template |
MiniJinja with i18n, HTMX detection, flash message integration |
sse |
Server-Sent Events with named broadcast channels |
email |
Markdown-to-HTML email rendering with SMTP |
storage |
S3-compatible object storage with ACL and upload-from-URL |
webhook |
Outbound webhook delivery with Standard Webhooks signing |
dns |
TXT/CNAME verification for custom domain validation |
geolocation |
MaxMind GeoIP2 location lookup with middleware |
auth::role |
Role-based access control with guard middleware |
tenant |
Multi-tenancy via subdomain, header, path, or custom resolver |
flash |
Signed, read-once cookie flash messages |
cron |
Cron scheduling (5/6-field expressions) |
health |
/_live and /_ready health check endpoints |
cache |
In-memory LRU cache |
testing |
TestDb, TestApp, TestSession — in-process, no server needed |
Every module is always compiled — there are no per-capability feature flags. The
only feature is test-helpers, enabled in your [dev-dependencies].
[dependencies]
modo = { package = "modo-rs", version = "0.10" }
[dev-dependencies]
modo = { package = "modo-rs", version = "0.10", features = ["test-helpers"] }Trade-off: modo deliberately keeps every module always-on so the public API stays one shape regardless of what you use. The cost is that every dependency (templating, email, geolocation, S3, sentry, etc.) compiles into your build, which adds ~20–40 s to a clean build and a few MB to the final binary. If you need to slim a deployment, build with
--releaseand rely on linker dead-code elimination — modules you don't reference don't ship runtime code, only their compile cost.
For handler-time imports, prefer the prelude:
use modo::prelude::*;The prelude brings in the ambient extractors, error/result types, and
validation traits that handlers reach for. Module-specific items
(modo::auth::session::CookieSessionService, modo::job::Worker, etc.) are
reached through their module path.
modo re-exports axum, serde, serde_json, and tokio so you don't need to version-match them yourself.
The modo-dev plugin gives Claude Code full knowledge of modo's APIs and conventions.
/plugin marketplace add dmitrymomot/modo
/plugin install modo@modo-dev
/reload-plugins
Once installed, it activates automatically when you build with modo. Or invoke it with /modo-dev.
cargo check # type check
cargo test --features test-helpers # run all tests
cargo clippy --features test-helpers --tests -- -D warnings # lint
cargo fmt --check # format checkApache-2.0