Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
@@ -0,0 +1,8 @@
+++
title = "Explanation"
description = "Understanding-oriented discussion of how Loco works and why it is designed the way it is."
template = "docs/section.html"
sort_by = "weight"
weight = 4
draft = false
+++
@@ -0,0 +1,93 @@
+++
title = "AppContext and dependency injection"
description = "Why AppContext is the one piece of shared state every handler sees, and how SharedStore lets you extend it without forking the framework."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 3
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
Rust doesn't let you reach for a mutable global app instance the way Rails or Django can — there's no ambient `current_app` to mutate from anywhere. Loco's answer to "how does a handler get at the database, the cache, the mailer, my own service client?" is a single, cheaply-cloneable struct threaded through the whole app: `AppContext`. This page explains why that struct has the shape it does, and how `SharedStore` extends it to things Loco itself doesn't know about.
## `AppContext` as the one shared-state object
Every handler, background worker, task, and scheduled job in a Loco app receives the same `AppContext` value — assembled once at boot (see [Architecture](@/docs/explanation/architecture.md)) and cloned cheaply wherever it's needed, because most of its fields are already `Arc<...>`-wrapped or otherwise cheap to clone. It carries eight fields:
```rust
#[derive(Clone, FromRef)]
pub struct AppContext {
pub environment: Environment,
#[cfg(feature = "with-db")]
pub db: DatabaseConnection,
pub queue_provider: Option<Arc<bgworker::Queue>>,
pub config: Config,
pub mailer: Option<EmailSender>,
pub storage: Arc<Storage>,
pub cache: Arc<cache::Cache>,
pub shared_store: Arc<SharedStore>,
}
```
The full field-by-field reference — types, feature gates, purpose — lives in [AppContext & prelude](@/docs/reference/app-context.md). What's worth explaining here is the *design*, not the field list:
- **One struct, not seven services.** Rather than injecting the DB pool, the cache, the mailer, storage, and the queue as five separate pieces of Axum state, Loco bundles them into one `AppContext` and derives `FromRef` on it. That derive is what lets a handler ask for exactly the piece it needs — `State<DatabaseConnection>` or `State<Arc<cache::Cache>>` — while a background worker or the boot sequence can still ask for the whole thing. You get the ergonomics of narrow, single-purpose extraction without the boilerplate of hand-writing `FromRef` impls for every field.
- **`db` is the only field that's compiled away, not just empty.** Every other field degrades gracefully when unconfigured (`None` for `mailer`/`queue_provider`, a `Null` driver for `cache`/`storage`) — an app with no mailer configured still has a `mailer: Option<EmailSender>` field, just set to `None`. `db` is different: with the `with-db` feature off, the field doesn't exist on the struct at all, which is a compile-time way of saying "this deployment shape genuinely has no database," rather than a runtime `Option` a caller could forget to check.
- **It's the same value everywhere.** Because `create_context` builds `AppContext` exactly once at boot and every subsystem downstream — routing, middleware, handlers, `connect_workers`, tasks, the scheduler — receives that same value, there's no risk of a handler and a background job disagreeing about which DB pool or cache instance is "the real one." This is also why `Hooks::after_context(ctx: AppContext) -> Result<AppContext>` (which runs immediately after assembly, before routes are built) is the one hook that can rewrite the context itself — it's your last and only chance to add something to it before it's handed out everywhere.
## The gap `AppContext`'s fixed fields can't fill
`AppContext`'s eight fields cover what *every* Loco app needs. They obviously can't cover what *your* app needs — a third-party API client, a feature-flag SDK handle, an app-specific cache of precomputed data. Two options exist, and they aren't in tension, they're the same design carried into two different lifecycles:
- **`Initializer`** (see [Add middleware](@/docs/how-to/add-middleware.md)) is the *install-time* extension point: a trait with `before_run`, `after_routes`, and `check` hooks, used to wire a whole piece of infrastructure into the app (register an Axum `Extension`, mount a session layer, install a doctor health check).
- **`SharedStore`** is the *storage* extension point: a place to actually hold a value of a type Loco has never heard of, so it can be read back out in a handler, a worker, or anywhere else `AppContext` reaches.
In practice they compose: you typically construct the value you want to share and call `ctx.shared_store.insert(..)` from inside `Hooks::after_context` (the same hook that runs once, right after the context is built), then read it back with the extractor below.
## `SharedStore`: a type-keyed DI container
`AppContext.shared_store: Arc<SharedStore>` is a small, concurrent, heterogeneous store — internally a `DashMap` keyed by `TypeId`, so it can hold one value of any number of distinct `'static + Send + Sync` types at once. Its API is deliberately minimal:
| Method | What it does |
|---|---|
| `insert<T>(&self, val: T)` | Store (or overwrite) the value for type `T`. |
| `get<T: Clone>(&self) -> Option<T>` | Fetch a *cloned* copy of `T`, if present. |
| `get_ref<T>(&self) -> Option<RefGuard<'_, T>>` | Fetch a borrowed `Deref<Target = T>` guard — for types that aren't `Clone`, or when a clone would be wasteful. |
| `remove<T>(&self) -> Option<T>` | Take the value back out. |
| `contains<T>(&self) -> bool` | Check presence without touching the value. |
### Reading it back: two paths, and a naming hazard to watch for
There are two distinct `SharedStore` types reachable from `loco_rs::prelude`, and confusing them is the single easiest mistake to make with this feature:
- **`loco_rs::app::SharedStore`** — the container type above, held as `ctx.shared_store`.
- **`loco_rs::controller::extractor::shared_store::SharedStore<T>(pub T)`** — an Axum `FromRequestParts<AppContext>` *extractor*, also re-exported as `SharedStore` from the prelude, that reaches into `ctx.shared_store`, clones out a `T`, and hands it to your handler as a plain argument:
```rust
#[debug_handler]
pub async fn index(
SharedStore(service): SharedStore<MyClonableService>,
) -> impl IntoResponse {
tracing::info!("api key: {}", service.api_key);
format::empty()
}
```
If `T` was never inserted, the extractor rejects the request with `Error::InternalServerError` — which is the right failure mode for "the app forgot to wire something up," as opposed to a client-facing 4xx. For a type that isn't `Clone`, skip the extractor and reach for `ctx.shared_store.get_ref::<T>()` directly off an ordinary `State<AppContext>` extraction instead — you get a reference-counted guard rather than a copy.
## Why this shape, instead of a global registry
The alternative designs are familiar from other ecosystems — a service locator singleton, or a compile-time DI container that resolves a dependency graph. Loco deliberately avoids both:
- A **global mutable singleton** isn't something safe Rust gives you for free, and reaching for `unsafe`/`OnceCell`-style globals to fake one would undermine the exact guarantee (no data races, no invisible mutation from anywhere) that makes Rust worth using for a server in the first place.
- A **compile-time DI framework** (resolving constructor graphs, macro-generated wiring) adds a second configuration language on top of Rust itself, for a problem `AppContext` plus `SharedStore` already solves at the cost of one `insert`/`get` pair.
`SharedStore` is intentionally closer to "a typed, thread-safe `HashMap<TypeId, Box<dyn Any>>` you're handed for free" than a general DI framework — it doesn't manage lifecycles, doesn't resolve dependencies between the things you store, and doesn't enforce a registration order. That's the trade: less power, but no new mental model to learn, and it composes with ordinary Rust ownership rather than working around it. For most apps, "stash a client in `after_context`, extract it with `SharedStore<T>`" is the entire pattern.
See the [AppContext & prelude reference](@/docs/reference/app-context.md) for the exhaustive field/method signatures, and [Add middleware](@/docs/how-to/add-middleware.md) for a related worked example (a custom `MiddlewareLayer`/`Initializer`-style extension wired into the app).
@@ -0,0 +1,120 @@
+++
title = "Architecture: the request lifecycle"
description = "How a Loco app boots, how AppContext gets built, and how a request travels through routes, middleware, and back out — and why the middleware order is LIFO."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 2
sort_by = "weight"
template = "docs/page.html"
aliases = ["/docs/the-app/your-project/"]
[extra]
lead = ""
toc = true
top = false
+++
A Loco app has two distinct timelines that are worth keeping separate in your head: **boot** (runs once, assembles everything the app needs) and **request handling** (runs per HTTP request, through a fixed pipeline). This page walks both, and explains the one piece of ordering that surprises almost everyone the first time: middleware runs in the *reverse* of the order you list it in.
## Boot: from `StartMode` to a running app
Everything starts from `src/boot.rs`, driven by the `Hooks` trait your `App` implements (the exhaustive method-by-method reference is [Hooks trait](@/docs/reference/hooks.md)). At a high level:
```text
cargo loco start
│
▼
Hooks::load_config(env) → Config (default: env.load())
│
▼
create_context::<App>(env, config) → AppContext (db, mailer, queue, cache, storage wired up)
│ Hooks::after_context(ctx) can rewrite ctx here
▼
db::converge + bgworker::converge (migrations / queue setup, if applicable)
│
▼
run_app::<App>(mode, ctx) → BootResult
│ ├─ Hooks::before_run(&ctx)
│ ├─ Hooks::initializers(&ctx) → before_run() on each Initializer
│ ├─ Hooks::routes(&ctx) → AppRoutes
│ ├─ Hooks::before_routes / after_routes(router, &ctx)
│ ├─ Hooks::middlewares(&ctx) → Vec<Box<dyn MiddlewareLayer>>
│ └─ after_routes() on each Initializer
▼
start::<App>(boot, server_config) → binds the socket, spawns the scheduler if requested,
calls Hooks::serve(...), prints the banner
```
Each `Hooks` method in that chain has a sensible default (see the reference for the exact signatures and defaults) — a minimal `App` only needs to implement `app_name`, `boot`, `routes`, `connect_workers`, `register_tasks`, and (with a database) `truncate`/`seed`. Everything else — logging setup, config loading, the middleware stack, initializer wiring — is a provided method you override only when you need to change it.
### `StartMode`: what actually runs in this process
`boot()` receives a `StartMode` that determines which of the app's subsystems are live in *this* process:
| Mode | Server | Worker | Scheduler |
|---|---|---|---|
| `ServerOnly` | yes | no | no |
| `ServerAndWorker` | yes | yes (same process) | no |
| `ServerAndScheduler` | yes | no | yes |
| `WorkerOnly { tags }` | no | yes, filtered by tag | no |
| `WorkerAndScheduler { tags }` | no | yes, filtered by tag | yes |
| `All` | yes | yes | yes |
`StartMode` exists because "the web server" and "the thing that drains the job queue" don't have to be the same OS process — in fact for anything beyond a single-dyno deployment you usually *want* them separate, so you can scale workers and the HTTP tier independently. `cargo loco start --worker`, `--server-and-worker`, `--scheduler`, and `--all` map directly onto these variants (`cargo loco start` alone is `ServerOnly`). The worker only actually runs if `workers.mode` in config is `BackgroundQueue` — see [The background-processing model](@/docs/explanation/background-processing-model.md) for why.
### `AppContext` is assembled once, here
`create_context` is the one place `AppContext`'s eight fields (`environment`, `db`, `queue_provider`, `config`, `mailer`, `storage`, `cache`, `shared_store`) get their real values, before `Hooks::after_context` gets a final chance to post-process the struct (e.g. to stash a custom service into `shared_store`). Everything downstream — routing, middleware, handlers, background workers, tasks, the scheduler — receives the *same* `AppContext` value (it's cheaply `Clone`), which is why it's the natural place to reach for shared state. See [AppContext and dependency injection](@/docs/explanation/appcontext-and-di.md) for the full story on that struct and its `shared_store` extensibility slot.
## Request handling: the onion
Once boot finishes, `AppRoutes::to_router` has compiled your routes plus the middleware stack into one real `axum::Router<AppContext>`. A request's journey through it looks like this:
```text
inbound request
│
▼
┌───────────────────────────────────────────┐
│ powered_by (outermost — first) │
│ ┌────────────────────────────────────┐ │
│ │ fallback (non-prod) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ request_id │ │ │
│ │ │ ┌────────────────────────┐ │ │ │
│ │ │ │ logger │ │ │ │
│ │ │ │ ┌──────────────────┐ │ │ │ │
│ │ │ │ │ … cors, etag, │ │ │ │ │
│ │ │ │ │ compression … │ │ │ │ │
│ │ │ │ │ ┌────────────┐ │ │ │ │ │
│ │ │ │ │ │limit_payload│ │ │ │ │ │
│ │ │ │ │ │ (innermost)│ │ │ │ │ │
│ │ │ │ │ │ ┌──────┐ │ │ │ │ │ │
│ │ │ │ │ │ │handler│ │ │ │ │ │ │
│ │ │ │ │ │ └──────┘ │ │ │ │ │ │
│ │ │ │ │ └────────────┘ │ │ │ │ │
│ │ │ │ └──────────────────┘ │ │ │ │
│ │ │ └────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└───────────────────────────────────────────┘
│
▼
response, unwinding back out the same layers
```
### Why the order is LIFO
`AppRoutes::to_router` builds this onion by calling `app.layer(...)` once per middleware, in the order `default_middleware_stack` lists them (`limit_payload` first, `powered_by` last). Axum's `Router::layer` wraps the *existing* router with each new layer as the new **outermost** layer. The consequence, stated directly in the framework's own source comment:
> "the LAST middleware is the FIRST to meet the outside world (a user request starting), or 'LIFO' order" — `src/controller/app_routes.rs`
So the coding/config order and the runtime order are opposites: routes are added first (they become the innermost core of the onion — the thing every layer eventually wraps), and the *last* middleware added (`powered_by`, at the bottom of the default list) is the *first* thing an inbound request actually passes through. `request_id` is deliberately near the end of the list (so it's near the *outside* at runtime) precisely because every request needs its ID assigned as early in its life as possible.
This matters practically whenever you reach for `Routes::layer(...)` to attach a `tower::Layer` to one controller, or override `Hooks::middlewares` to reorder the default stack — get the direction backwards and a middleware that's supposed to run before authentication ends up running after it. The full ordered list, with each middleware's config key and default-enabled state, is in the [middleware catalog reference](@/docs/reference/middleware.md); [Add middleware § 5](@/docs/how-to/add-middleware.md#5-write-a-custom-middleware) shows how to hand-write a `tower::Layer` middleware of your own that participates in the same onion.
## Where this leaves the handler
By the time your handler runs, it's just a normal Axum handler function taking normal Axum extractors (`State<AppContext>`, `Json<T>`, `Path<T>`, and so on — nothing Loco-specific is required). The response side of the onion is symmetric: your `impl IntoResponse` (or Loco's `format::` helpers) produces a `Response`, which then unwinds back out through the same middleware stack in reverse, each layer getting a chance to post-process it (compression, headers, logging the outcome) before it leaves the process.
For the mechanics of how routes get their axum `Router<AppContext>` shape (`AppRoutes`, `Routes`, prefixing, nesting) see [Add a controller](@/docs/how-to/add-controller.md); for how this whole model maps onto plain Axum concepts you already know, see [Coming from Axum](@/docs/explanation/coming-from-axum.md).
@@ -0,0 +1,81 @@
+++
title = "The background-processing model"
description = "Why perform_later works unmodified against Redis, Postgres, or SQLite, how the shared Driver trait keeps the two SQL backends in lockstep, and what priority and worker modes buy you."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 5
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
Loco lets you write one `BackgroundWorker` implementation and one `perform_later` call site, then choose — by config, not by code change — whether jobs are durably queued in Redis, Postgres, or SQLite, or not durably queued at all. This page explains the design that makes that swap safe, not the step-by-step of adding a worker (that's [Add a background worker](@/docs/how-to/add-worker.md)) or the exhaustive config keys (that's the [Configuration reference](@/docs/reference/configuration.md#queue) and [Choose a queue backend](@/docs/how-to/choose-queue-backend.md)).
## One trait, one call site, three backends
```rust
#[async_trait]
pub trait BackgroundWorker<A> {
fn build(ctx: &AppContext) -> Self;
async fn perform(&self, args: A) -> Result<()>;
// + queue(), tags(), class_name(), perform_later(), perform_later_with_priority()
}
```
You implement `perform`, register the worker in `connect_workers`, and enqueue work with `MyWorker::perform_later(&ctx, args).await?`. Nothing in that call references which backend is active — that's decided entirely by `queue.kind` in config (`Redis` | `Postgres` | `Sqlite`), read at boot by `create_queue_provider`. This is the same "config over code" bias covered in [Why batteries included](@/docs/explanation/why-batteries-included.md), applied to durability and delivery semantics: swapping backends is an operational decision (what's already running in your infrastructure, what latency/throughput profile you need), not a rewrite.
`perform_later` (and its sibling `perform_later_with_priority`) returns `Result<String>` — the job's id — rather than `Result<()>`. That return value matters because it's what you'd hand to `cargo loco jobs cancel`/`requeue` or log for later correlation; treat any `perform_later` call site that discards its return value as intentionally choosing not to track the job, not as the only option.
## The two SQL backends share one implementation
Postgres and SQLite queueing used to be two independent, parallel implementations that had to be kept in sync by hand. As of the 1.0 line they're de-duplicated behind one internal `Driver` trait:
```rust
pub(crate) trait Driver {
type Pool;
fn idle_count(&self) -> ...;
async fn dequeue(pool: &Self::Pool, tags: &[String]) -> ...;
async fn complete_job(pool: &Self::Pool, id: ..., interval: ...) -> ...;
async fn fail_job(pool: &Self::Pool, id: ..., error: ...) -> ...;
}
```
The `Job` model, the polling/registration loop (`JobRegistry`), panic-catching around `perform`, and the run-loop machinery all live once, generic over `Driver`. `PgDriver` and `SqliteDriver` only need to supply the three DB operations above plus a pool type — everything else (worker registration, tag filtering, graceful cancellation) is shared code, not two copies that can drift. This is why Postgres and SQLite have identical *behavior* (same admin operations, same priority semantics, same job lifecycle) even though the underlying SQL is necessarily different — one uses `FOR UPDATE SKIP LOCKED` for concurrent dequeue, the other simulates it with a lock table since SQLite has no equivalent. Redis, being architecturally different (no SQL, no row locking), keeps its own independent run loop rather than implementing `Driver` — but is still held to the same external contract (the same `Queue` API, the same job lifecycle, the same admin operations) from the outside.
That shared contract is what lets `cargo loco jobs cancel|tidy|purge|dump|import|requeue` work identically regardless of which backend is configured — including Redis, which historically lagged the SQL backends on admin-operation support but is now at parity.
## Priority: one semantic, three storage strategies
All three backends dequeue by priority first, then by age: a higher `i32` priority value is more urgent, ties break by earlier `run_at`, then by a stable job id. How each backend *stores* that ordering differs with its storage model, which is worth understanding since it explains the backends' relative strengths:
- **Postgres / SQLite** add a `priority` column and an `ORDER BY priority DESC, run_at, id` on dequeue (existing pre-1.0 tables are auto-migrated to add the column). This is a natural fit for a row store with a query planner.
- **Redis** has no query planner to lean on, so priority is encoded structurally: jobs live in a sorted set (ZSET) scored by *negative* priority, so the highest-priority job sorts first under `ZRANGE`'s ascending order — with `run_at`/id used as an explicit tie-break in the dequeue logic, since the score alone can't carry three levels of ordering.
Redis additionally supports **named queues** (`queue.queues: [high, low, ...]`, first = most important) with two independent workers backed by the default `["default", "mailer"]` queues, and a `Worker::queue()` override to route a specific worker's jobs into one. This is a coarser-grained tool than per-job priority — named queues partition *which pool of workers* picks up a job, while `priority` decides ordering *within* that pool — and the two compose (a named queue can still be priority-ordered internally).
## Worker modes: trading durability for simplicity
`workers.mode` is a separate axis from the queue backend — it decides whether a persistent queue is even in the picture:
| Mode | Durable across restarts? | Where jobs run | Typical use |
|---|---|---|---|
| `BackgroundQueue` (default) | yes | a separate worker process/thread, dequeuing from the configured `queue:` backend | production |
| `ForegroundBlocking` | n/a — runs inline | the calling request/task, synchronously | tests, where you want deterministic execution before asserting on side effects |
| `BackgroundAsync` | no — lost on crash | `tokio::spawn` in the same process | low-stakes, best-effort work where standing up a queue backend isn't worth it |
The reason this is a mode switch rather than a code difference is the same reason the backend is a config switch: `perform_later` and `perform` don't change, so a worker written and tested under `ForegroundBlocking` behaves identically once the app is switched to `BackgroundQueue` in production — the only thing that changes is *when* and *where* `perform` actually executes, not its logic.
## Choosing a backend
There's no universally correct choice — the three backends trade off along real infrastructure axes:
- **Redis** — lowest latency, named/priority queues, no schema to manage; the right default if Redis is already part of your stack.
- **Postgres** — no new infrastructure if your app's primary database is already Postgres, and `FOR UPDATE SKIP LOCKED` gives solid concurrent-worker throughput.
- **SQLite** — zero extra infrastructure at all, good for small deployments or local development; the lock-table fallback for concurrency makes it less suited to a large number of concurrent workers than the other two.
See [Choose a queue backend](@/docs/how-to/choose-queue-backend.md) for the concrete config for each, and the [Configuration reference](@/docs/reference/configuration.md#queue) for every field and its default.
@@ -0,0 +1,60 @@
+++
title = "Coming from Axum"
description = "Loco is Axum 0.8 with pre-wired decisions on top, not a replacement for it — how extractors, State, and the Router map across, and what Loco actually adds."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 7
sort_by = "weight"
template = "docs/page.html"
aliases = ["/docs/getting-started/axum-users/"]
[extra]
lead = ""
toc = true
top = false
+++
If you already know [Axum](https://crates.io/crates/axum), you already know most of Loco — the framework compiles down to a real `axum::Router<AppContext>`, uses the same `FromRequestParts`/`FromRequest` extractor model, and pins Axum 0.8. This page is about the delta: what Loco pre-wires on top, and how the concepts you already have a mental model for (extractors, `State`, the `Router`) map onto Loco's names for the same things, plus the mechanics of moving a real Axum codebase over; for the request lifecycle these concepts sit inside, see [Architecture](@/docs/explanation/architecture.md).
## The core claim: nothing is hidden, a lot is pre-decided
Loco is not a new web framework with an Axum-shaped API — it *is* Axum, with a layer of default decisions and a `Hooks` trait that assembles them consistently across every app that uses it. Every extractor you already know still works unmodified; the state type is just a specific struct (`AppContext`) instead of whatever ad-hoc struct you'd have hand-rolled; and the middleware you'd have written as `tower::Layer`/`Service` impls yourself either already exists as a config-toggleable built-in, or you write it exactly the way you would in plain Axum and attach it the same way. [Why batteries included](@/docs/explanation/why-batteries-included.md) covers the philosophy; this page covers the mechanical mapping.
## Concept mapping
| Axum concept | Loco equivalent | What changed |
|---|---|---|
| Your own `main()` assembling the router, state, and `axum::serve(...)` | `Hooks::boot` → `create_app`/`create_context`, `Hooks::serve` (default calls `axum::serve` for you) | You describe *what* to wire (routes, workers, tasks) via `Hooks`; Loco's boot sequence (see [Architecture](@/docs/explanation/architecture.md)) does the assembling. `cargo loco start` replaces a hand-written `main.rs` entirely — a generated app doesn't need one. |
| A hand-rolled `ApiContext` struct + `AddExtensionLayer`/`State` | `AppContext` (8 fields: `environment`, `db`, `queue_provider`, `config`, `mailer`, `storage`, `cache`, `shared_store`), `#[derive(FromRef)]` | Same idea (one struct, threaded as Axum `State`) but pre-built with the pieces almost every service needs, and `FromRef`-derived so you can extract a single field (`State<DatabaseConnection>`) instead of always the whole context. See [AppContext and dependency injection](@/docs/explanation/appcontext-and-di.md). |
| `Router::new().route("/x", get(handler))` | `Routes::new().add("/x", get(handler))`, collected into `AppRoutes` | `Routes`/`AppRoutes` are a thin builder over the same `MethodRouter`/`Router` types — `add`, `prefix`, `nest_route`, `merge`, and `layer` all compile down to the Axum calls you'd write by hand, plus route metadata (`cargo loco routes` listing) that a plain Axum `Router` can't give you back. |
| `.layer(SomeTowerLayer::new())` on a router or route | `Routes::layer(..)` (per-route) or `Hooks::middlewares` (app-wide) | Identical `tower::Layer`/`Service` code — Loco doesn't wrap or reinterpret Tower's traits. The app-wide default stack (CORS, compression, timeouts, etc.) is config-toggled rather than hand-attached; see the [middleware catalog](@/docs/reference/middleware.md) and its LIFO ordering note in [Architecture](@/docs/explanation/architecture.md#why-the-order-is-lifo). |
| `Extension<T>` / custom `FromRequestParts` impls for app-specific data | `State<AppContext>` field extraction, or `SharedStore<T>` for anything not already a field | See [AppContext and dependency injection](@/docs/explanation/appcontext-and-di.md) for when to reach for which. |
| `dotenv` + manual env var parsing in `main` | Typed `Config` loaded from `config/{env}.yaml`, Tera's `get_env` for env-var interpolation | See [The configuration model](@/docs/explanation/configuration-model.md). |
| `env_logger`/`tracing_subscriber` set up by hand | `logger::init` (default), or return `Ok(true)` from `Hooks::init_logger` to opt out entirely and own it yourself | Loco's default filters out third-party log noise you didn't ask for while still using plain `tracing` underneath — any crate emitting `tracing` events shows up the same way it would under a hand-rolled subscriber. |
| A router you already have, that you don't want to restructure | Return it untouched from `Hooks::before_routes`/`after_routes` | This is the literal drop-in path: mount an existing `axum::Router` as-is and keep every extractor and handler signature you already wrote. |
## What "drop-in compatible" actually buys you
Because routing metadata is optional rather than mandatory, you have a genuine choice at the boundary between "paste in existing Axum code" and "get Loco's introspection for free":
- Return your existing router verbatim from `after_routes(router, _ctx)` — zero changes to handler signatures, zero changes to how routes are declared, full compatibility, but `cargo loco routes` won't know about those routes (Axum doesn't expose method/path metadata off a live `Router`, which is precisely the gap `Routes`/`AppRoutes` exist to close).
- Rewrite route declarations from `Router::new().route(path, method(handler))` to `Routes::new().add(path, method(handler))` — this is a mechanical, same-shape edit (the handler itself, its extractors, and its return type are untouched) — and get route listing, prefixing/nesting helpers, and per-route `tower::Layer` attachment (`Routes::layer`) back.
Most apps end up doing the second for their own controllers and the first only for vendored or generated routers they don't want to touch.
## What Loco adds that plain Axum genuinely doesn't have
Everything in the mapping table above is a *reframing* of something Axum already gives you. The following are not reframings — they're capabilities with no direct Axum equivalent, because they're above the HTTP layer:
- A background-job system (`BackgroundWorker`, three interchangeable queue backends) — see [The background-processing model](@/docs/explanation/background-processing-model.md).
- A cron-like scheduler and a CLI task runner, both driven from the same app.
- Database access via Sea-ORM with a generator that scaffolds models/migrations from a field-type DSL.
- A `cargo loco` CLI: `routes`, `middleware --config`, `doctor`, `jobs`, `db`, `generate`.
- Structured JWT/API-key auth extractors, a cache abstraction, and a multi-driver storage abstraction, each swappable by config rather than by code change.
These are the parts of "batteries included" that live outside the request/response cycle Axum itself models — which is also why they're covered by their own pages in this cluster rather than in this one's extractor/router mapping.
## A note on versions
Loco 1.0 tracks Axum 0.8 and targets Rust edition 2024 (the `loco-rs` crate itself; apps generated by `loco new` currently still default to edition 2021 in their own `Cargo.toml` — bump it yourself if you want 2024-edition semantics, such as the `unsafe`-required `std::env::set_var`, in your own app code). If you're moving code from an Axum service built against an older Axum release, the usual Axum 0.7→0.8 migration notes apply on top of everything above — nothing here changes because of Loco.
@@ -0,0 +1,69 @@
+++
title = "The configuration model"
description = "How Loco resolves an environment, which config file wins, why YAML is rendered through Tera first, and how secrets flow in without a dedicated vault type."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 4
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
Every subsystem described elsewhere in this cluster — the DB pool, the queue backend, the cache, the mailer, the middleware stack — is switched on and tuned from one place: a per-environment YAML file, deserialized into one typed `Config` struct. This page explains the small number of rules that govern how that file is found, rendered, and trusted with secrets. For the exhaustive key-by-key listing, see the [Configuration reference](@/docs/reference/configuration.md).
## Why a typed config struct, not ad-hoc env vars
The alternative most hand-rolled Axum services fall into is reading a scatter of environment variables (`DATABASE_URL`, `PORT`, `RUST_LOG`, ...) directly in `main()`, each parsed and defaulted slightly differently, with no single place that shows what the app's full configuration surface even is. Loco instead deserializes the whole environment file into one `Config` struct (`src/config/mod.rs`), with every sub-area — `server`, `database`, `logger`, `queue`, `cache`, `mailer`, `auth`, `workers` — as a typed field with `serde` defaults where a sane one exists and a hard requirement (a missing-field deserialize error at boot) where there isn't one. This is the same "prefer a built-in over hand-wiring" bias covered in [Why batteries included](@/docs/explanation/why-batteries-included.md), applied specifically to app configuration: you get one document that *is* the app's configuration surface, checked at boot rather than discovered at the call site that happens to read an env var.
## Which environment, and which file
Two independent questions get resolved before any YAML is even opened:
**Which environment name?** `environment::resolve_from_env()` checks, in order:
1. `LOCO_ENV`
2. `RAILS_ENV`
3. `NODE_ENV`
4. falls back to `"development"`
The `RAILS_ENV`/`NODE_ENV` fallbacks exist so that a Loco app dropped into infrastructure already standardized on Rails- or Node-style environment naming doesn't need a separate variable just for Loco.
**Which file, for that environment name?** `Config::from_folder` picks the *first* file that exists, in this order:
1. `{env}.local.yaml`
2. `{env}.yaml`
If neither exists, boot fails outright with "no configuration file found." The `.local.yaml` tier is the mechanism for machine-local overrides — a developer's own DB credentials, a locally-running service's port — that should never be checked into version control alongside the shared `{env}.yaml`. It's a convention, not a special format: `development.local.yaml` is parsed with exactly the same rules as `development.yaml`, it's just consulted first and expected to be gitignored.
Both tiers are read from a `config/` folder by default; `LOCO_CONFIG_FOLDER` overrides that location, which matters for deployments that mount configuration from somewhere other than the app's own source tree.
## The YAML is a Tera template first, a config file second
Before `serde_yaml` ever sees the file, its entire contents are rendered as a [Tera](https://keats.github.io/tera/) template (`Tera::one_off(.., autoescape = false)`). This is a small design choice with a real consequence: it's what makes patterns like this legal inside a Loco config file at all —
```yaml
server:
port: {{ get_env(name="NODE_PORT", default=5150) }}
```
`get_env(name=.., default=..)` here is **Tera's own built-in function**, not something Loco registers. Loco doesn't have a custom templating layer bolted onto YAML — it reuses a general-purpose template engine's existing capability (reading env vars, with a default) so that config files can be static-looking YAML *and* environment-aware at the same time, without inventing a second interpolation syntax. Anything else Tera can do in a one-off render (conditionals, other built-in functions) is available in a config file too, though `get_env` covers the overwhelming majority of real use.
The practical implication: a config value that looks hardcoded may not be — always check for `{{ }}` before assuming a YAML value is literal — and a value that needs to differ between "what's checked into git" and "what's true on this machine/host" belongs behind `get_env`, not behind a second config file.
## Secrets: a convention, not a vault type
There is no dedicated `Secret` type or vault integration built into `Config`. A JWT secret, an SMTP password, a database URI — these are all just `String` fields on ordinary config structs (`auth.jwt.secret`, `mailer.smtp.auth.password`, `database.uri`). The secrets *model* is the composition of two things already covered above:
- Secret values are injected via `get_env(name=..)` at render time, so the checked-in YAML never contains the literal secret — only the name of the environment variable to read it from.
- Machine-local secrets that shouldn't even have an env-var name in shared code can go in `{env}.local.yaml` instead, which is expected to be gitignored entirely.
This is deliberately unopinionated about *where* the environment variable itself comes from — a `.env` file, a process manager, a secrets manager injecting env vars at container start — because that's an operational concern outside the framework's scope, and Loco's contract stops at "a `String` field, populated from `get_env` or a local override file." One consequence worth knowing in advance: `auth.jwt.secret` specifically is expected to be valid **base64** (it's fed to `jsonwebtoken`'s `from_base64_secret` constructors) — a plain passphrase string will fail at the point the JWT extractor tries to decode it, not at config-load time.
## What this buys you day to day
Put together, the model gives you: one typed document per environment describing the whole app, a predictable override tier for anything machine-specific, and a templating escape hatch for anything environment-dependent — all without a second configuration DSL or a runtime service to stand up just to manage config. Changing a pool size, flipping a middleware on, or pointing at a different queue backend (see [The background-processing model](@/docs/explanation/background-processing-model.md)) is a YAML edit and a restart, not a recompile — the same "config, not code" theme that runs through the rest of Loco's built-ins. See the [Configuration reference](@/docs/reference/configuration.md) for every key, type, and default across every sub-config struct.
@@ -0,0 +1,59 @@
+++
title = "Views and assets"
description = "SSR with Tera vs. serving a SPA vs. embedding everything into the binary — and how one feature flag coordinates a swap across two subsystems at once."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 6
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
"How does this response get to the browser" has three different shapes in a Loco app — server-rendered HTML, a JSON API behind a separately-built SPA, or a fully embedded single binary — and Loco lets you pick without changing how controllers work. This page explains the model behind that choice. For the how-to of writing a specific view or wiring the static middleware, see [Render server-side views](@/docs/how-to/render-views.md); for the exhaustive middleware/config keys, see the [middleware catalog](@/docs/reference/middleware.md) and [feature flags reference](@/docs/reference/feature-flags.md).
## The separation controllers don't have to care about
Loco keeps the traditional split of responsibilities — a controller parses the request and calls into models; a *view* is responsible only for shaping the final response — and makes that split concrete with one trait:
```rust
pub trait ViewRenderer {
fn render<S: Serialize>(&self, key: &str, data: S) -> Result<String>;
}
```
A controller never talks to Tera, or to any specific templating engine, directly — it takes a `v: impl ViewRenderer` (typically via the `ViewEngine<E>` extractor) and calls `format::render().view(&v, "home/hello.html", data!({..}))`. The engine behind that trait is decided once, at the `Initializer` level — swap `ViewEngine<TeraView>` for `ViewEngine<YourEngine>` and every existing call site keeps compiling, because it was only ever coupled to the trait, not to Tera specifically. This is the same escape-hatch pattern described in [Why batteries included](@/docs/explanation/why-batteries-included.md): Tera is the built-in, `ViewRenderer` is the seam you use if you need something else.
For pure JSON APIs, "the view" can be as simple as a `#[derive(Serialize)]` struct shaped by hand and returned via `format::json(..)` — no template engine in the loop at all. Most real apps mix both: JSON views for an API surface, Tera views for a handful of server-rendered pages (an admin panel, a marketing page, an email-verification landing page).
## Three deployment shapes, one set of controller code
### Server-side rendering (Tera)
The default shape: `TeraView` reads templates from `assets/views/**/*.html` on disk at request time, alongside static files served from `assets/static/` through the `static` middleware. This is the natural choice when the app itself renders the HTML the browser gets — templates can be edited without a rebuild (`cargo loco start` picks up a changed `.html` file on the next request), which matters during active UI development.
### Client-side rendering (SPA)
Here Loco's job shrinks to two things: serve a JSON API, and serve the SPA's *built* static assets (the `dist/`-style output of a separate frontend build) through the same `static` middleware, usually with a fallback to `index.html` for client-side routing. There's no `TeraView` in the picture for the API surface at all — the split between "backend serves data" and "frontend owns rendering" is total, and Loco's role is just: API controllers, plus a static file server pointed at wherever the frontend build lands.
### `embedded_assets`: one flag, two subsystems swapped together
`embedded_assets` is a build-time Cargo feature that changes *where the bytes come from* without changing a single controller or view call:
```toml
loco-rs = { version = "...", features = ["embedded_assets"] }
```
With it enabled, the entire `assets/` directory — templates *and* static files — is scanned at compile time and embedded directly into the binary. What makes this worth calling out as a distinct architectural idea, rather than just "a smaller deployment," is that it isn't one subsystem being swapped: **both** the Tera view engine and the static-assets middleware are simultaneously replaced with embedded-reading variants (`views::engine_embedded` in place of `views::engine`, `static_assets_embedded` in place of `static_assets`) behind the same `#[cfg(embedded_assets)]` gate. One flag flips two independently-registered subsystems in lockstep, so a template lookup and a static-file request both resolve against the same in-binary asset table rather than one reading disk and the other reading memory. From application code, nothing changes — the `ViewEngine`/`ViewRenderer` and static-middleware config keys are identical either way.
The trade-off is the mirror image of SSR's live-editing convenience: a single-binary deploy with atomic code/asset updates and no filesystem asset directory to manage in production, at the cost of a full recompile for any asset change and a larger binary. Projects often use plain filesystem assets in development (fast iteration) and flip `embedded_assets` on for release builds (simpler deployment) — the same controller and view code runs unmodified in both.
## Why this is one flag and not per-subsystem toggles
It would be possible to let the view engine and the static middleware be embedded independently — but that would create deployment shapes where templates are baked into the binary while static files are read from disk (or vice versa), with no clear operational benefit and a real risk of the two drifting (a rebuild updates embedded templates but not the separately-deployed static folder, or the reverse). Coupling both under one feature flag makes "embedded" a single, coherent deployment mode rather than a matrix of partial states to reason about — consistent with the broader theme in [Why batteries included](@/docs/explanation/why-batteries-included.md) of collapsing a class of decisions into one well-tested default, with a clearly labeled way to opt out (here, just don't enable the feature).
See [Render server-side views](@/docs/how-to/render-views.md) for the concrete `assets/` directory layout, writing a Tera view and its controller wiring, and swapping in a custom `ViewRenderer`; see the [feature flags reference](@/docs/reference/feature-flags.md) for `embedded_assets`'s place in the full flag matrix, and the [middleware catalog](@/docs/reference/middleware.md#8-static) for the `static` middleware's config keys.
@@ -0,0 +1,61 @@
+++
title = "Why \"batteries included\"?"
description = "The prime directive behind Loco's design: prefer a built-in or a generator over hand-wiring, and what that buys you."
date = 2026-07-03T00:00:00+00:00
updated = 2026-07-03T00:00:00+00:00
draft = false
weight = 1
sort_by = "weight"
template = "docs/page.html"
[extra]
lead = ""
toc = true
top = false
+++
Loco's tagline is "Axum with batteries included," and it is meant literally: everything under the hood is standard [Axum](https://crates.io/crates/axum) 0.8 and [Tower](https://crates.io/crates/tower), but almost none of the code you'd normally write to assemble a production web service in Rust — wiring a DB pool into state, picking a logging stack, hand-rolling a queue, choosing a config format — is code you have to write yourself. This page explains the design principle behind that choice, not the mechanics (those live in [Architecture](@/docs/explanation/architecture.md) and the reference pages).
## The prime directive
When a Loco app needs a capability, the framework's bias is:
1. **Reach for a built-in first.** Database access, caching, background jobs, mailing, file storage, view rendering, JWT auth, health checks, a CLI — these already exist, wired into `AppContext` and toggled from YAML.
2. **Reach for a generator second.** `cargo loco generate` scaffolds the idiomatic shape of a model, controller, worker, mailer, task, or full CRUD scaffold. The generated code is not a black box — it's a starting point you own and edit.
3. **Hand-wire only as a last resort**, and when you do, Loco gives you a small number of well-defined seams to do it safely — `Hooks`, `Initializer`, `SharedStore`, `before_routes`/`after_routes` — rather than forcing you to fork the framework or reassemble `main()` from scratch.
This ordering is the single idea that explains most of what looks, from a plain-Axum perspective, like "magic": it isn't magic, it's a library of pre-wired decisions with an escape hatch at every layer.
## What "hand-wiring" looks like without it
A typical Axum service starts every project by re-deciding things that have already been decided a thousand times: which connection-pool settings, which logging crate, how to get the DB handle into every handler, how config and secrets flow in, how a background job survives a restart. None of these decisions are hard, but making them **again**, per project, is where hours disappear and where inconsistency creeps in between a team's services.
Loco's [`coming-from-axum`](@/docs/explanation/coming-from-axum.md) page walks through this delta concretely (pool setup, `AddExtensionLayer` state wiring, `env_logger` vs `tracing`, `main.rs` assembly) for a real reference app. The short version: every one of those steps becomes either a YAML key or a generator invocation in Loco, and the underlying Axum `Router`/`State`/extractor model is unchanged — so nothing about Axum's own learning curve is hidden from you.
## What Loco integrates
Batteries, concretely, means the following are already implemented, tested, and reachable from `AppContext` or a `Hooks` default, rather than left as an exercise:
- **Routing & the request pipeline** — `AppRoutes`/`Routes` compile down to a real `axum::Router<AppContext>`; a documented, ordered stack of middleware (payload limits, CORS, compression, timeouts, security headers, request IDs, a static file server, a dev-mode fallback page, and more) is available with a config flip rather than a `tower::Layer` you write by hand. See [Architecture](@/docs/explanation/architecture.md) and the [middleware catalog](@/docs/reference/middleware.md).
- **Data & persistence** — Sea-ORM 2.0 entities, migrations, and a query/pagination layer are generated from a compact field-type DSL (`cargo loco generate model ...`), not written by hand column-by-column.
- **Background processing** — one `BackgroundWorker` trait and one `perform_later` call site work unmodified against three interchangeable queue backends (Redis, Postgres, SQLite); see [The background-processing model](@/docs/explanation/background-processing-model.md).
- **Scheduling & tasks** — a cron-like scheduler (English or cron syntax) and ad-hoc CLI-invokable tasks, both driven from the same `Tasks`/`Hooks` registration points, no separate process supervisor to build.
- **Caching, storage, mail** — a `Cache` with in-memory/Redis/null backends, a multi-driver `Storage` abstraction (local/memory/S3/Azure/GCS) with single and replicated (mirror/backup) strategies, and a `Mailer` with SMTP/STARTTLS/implicit-TLS and a stub-for-tests mode — each a field on `AppContext`, each swappable by config or feature flag rather than by rewriting call sites.
- **Views** — server-rendered Tera templates, JSON views, or a single-binary `embedded_assets` build, chosen without touching controller code. See [Views and assets](@/docs/explanation/views-and-assets.md).
- **Auth & security** — JWT (HS512 by default, multi-location token extraction) and API-key extractors implementing the same `FromRequestParts` pattern as everything else in Axum.
- **Operability** — structured `tracing` logging with sane third-party filtering out of the box, `/_ping`/`/_health`/`/_readiness` endpoints, a `cargo loco doctor` diagnostic command, and a `cargo loco routes`/`middleware` introspection CLI.
- **Configuration** — one typed `Config` struct, one environment-resolution rule, one file-precedence rule, and Tera's own `get_env` for secrets — see [The configuration model](@/docs/explanation/configuration-model.md).
None of this requires a plugin marketplace or a runtime registry: it's all compiled into `loco-rs` behind Cargo feature flags (see the [feature-flags reference](@/docs/reference/feature-flags.md)), so an app only pays for what it turns on.
## The corollary: escape hatches, not walls
"Batteries included" only works as a philosophy if it doesn't become "batteries mandatory." Every built-in in Loco has a documented seam for replacing or bypassing it:
- Don't like the default middleware stack? Override `Hooks::middlewares` and return your own `Vec<Box<dyn MiddlewareLayer>>`.
- Want a raw Axum router mounted verbatim? `Hooks::before_routes`/`after_routes` hand you a real `axum::Router` to mutate directly — the [Coming from Axum](@/docs/explanation/coming-from-axum.md) page shows this as the literal drop-in path for existing Axum code.
- Need a service that isn't a first-class `AppContext` field (a third-party API client, a feature-flag SDK)? `AppContext.shared_store` is a type-keyed DI container built for exactly that — see [AppContext and dependency injection](@/docs/explanation/appcontext-and-di.md).
- Want to own the tracing/logging stack yourself? Return `Ok(true)` from `Hooks::init_logger` and Loco steps aside.
- Want a different view engine than Tera? Implement `ViewRenderer` and swap it in via an `Initializer`.
This is the same shape as Rails' "convention over configuration," reframed for a language where a global mutable app instance isn't an option: Loco supplies the convention as a compiled-in default, and the configuration/override points are explicit, typed, and ordered — not implicit and discoverable only by reading source. The rest of this Explanation cluster works through each of those seams — boot lifecycle, DI, config loading, background jobs, views, and the Axum relationship — in more depth.