# modo
> **modo** (Latin: "way, method") — a Rust web framework for small monolithic apps.
[](https://github.com/dmitrymomot/modo/actions/workflows/ci.yml)
[](https://docs.rs/modo-rs)
[](LICENSE)

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](https://github.com/tokio-rs/axum), 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.
## Why modo
**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.
## Quick look
```rust
use modo::{Config, Result};
use modo::axum::{Router, routing::get};
use modo::runtime::Task;
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
}
```
## What's included
### Config that just works
YAML files with `${ENV_VAR}` and `${ENV_VAR:default}` substitution, loaded per `APP_ENV`. No builder, no manual env parsing, no `.env` ceremony.
```yaml
# config/production.yaml
server:
port: ${PORT:8080}
database:
url: ${DATABASE_URL}
```
```rust
let config: Config = modo::config::load("config/")?;
```
### Database without an ORM
SQLite via libsql. A single `Database` handle wraps an `Arc`-ed connection — clone-friendly, no pool complexity.
```rust
let pool = db::connect(&config.database).await?;
db::migrate("migrations/", &pool).await?;
```
### Sessions with zero glue code
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:
```rust,ignore
// 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:
```rust,ignore
// 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?))
}
```
### Auth without a framework
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.
```rust
let hash = auth::password::hash(password, &PasswordConfig::default()).await?;
let valid = auth::password::verify(password, &hash).await?;
let totp = Totp::from_base32(secret, &TotpConfig::default())?;
let ok = totp.verify(user_code);
```
### Background jobs as plain functions
SQLite-backed queue with retries, exponential backoff, timeouts, scheduled execution, and idempotent enqueue. Handlers use the same extraction pattern as HTTP routes.
```rust
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(&pool).enqueue("send_email", &payload).await?;
```
### Graceful shutdown in one line
The `run!` macro waits for SIGTERM/SIGINT, then shuts down each component in declaration order. No cancellation tokens, no orchestration code.
```rust
modo::run!(worker, server).await
```
### Dependency injection without macros
`Registry` is a typed map. `.add()` at startup, `Service<T>` in handlers. No `#[inject]`, no container config, no runtime reflection.
```rust
let mut registry = Registry::new();
registry.add(pool);
registry.add(mailer);
// In any handler:
async fn list_users(Service(pool): Service<Pool>) -> Result<Json<Vec<User>>> { ... }
```
### Request extraction with auto-sanitization
`JsonRequest<T>`, `FormRequest<T>`, and `Query<T>` call your `Sanitize` impl before the handler runs. Define it once, applied everywhere.
### Middleware you'd write anyway
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.
### And the rest
| `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|
## Installation
Every module is always compiled — there are no per-capability feature flags. The
only feature is `test-helpers`, enabled in your `[dev-dependencies]`.
```toml
[dependencies]
modo = { package = "modo-rs", version = "0.8" }
[dev-dependencies]
modo = { package = "modo-rs", version = "0.8", 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 `--release` and 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:
```rust
use modo::prelude::*;
```
The prelude brings in the ambient extractors, error/result types, and tracing
macros that handlers reach for. Module-specific items (`modo::auth::session::Store`,
`modo::job::Worker`, etc.) are reached through their module path.
## Re-exports
modo re-exports `axum`, `serde`, `serde_json`, and `tokio` so you don't need to version-match them yourself.
## Claude Code Plugin
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`.
## Development
```sh
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 check
```
## License
Apache-2.0