12 KiB
Building Loco apps — agent guide
This file teaches an AI agent how to build Loco (loco.rs) applications correctly. Loco is an all-in-one, batteries-included Rust web framework (think "Rails for Rust"): one binary and one set of conventions give you routing, an ORM, background jobs, a scheduler, mailers, tasks, storage, caching, and testing. Because it is batteries-included, the single most common failure mode for LLMs is reaching for external crates and hand-wiring infrastructure that Loco already provides. Prefer Loco's built-ins and generators.
This guide targets Loco 1.0 (Sea-ORM 2.0, sqlx 0.9, edition 2024 for the framework itself — generated apps are still edition 2021). For prose docs see https://loco.rs/docs, and for a single-file reference see https://loco.rs/llms-full.txt.
Golden rules
- Use the generators.
cargo loco generate model|scaffold|controller| worker|task|scheduler|mailer|migration|deployment|override ...writes correct, convention-following code. Generate, then edit — don't hand-write boilerplate.scaffold/controllerrequire exactly one of--api/--html/--htmx(no default; omitting all is a hard error). - Everything hangs off
AppContext. Handlers, workers, tasks, and initializers receive&AppContext(ctx), with 8 fields:db(with-dbonly),config,mailer,storage,cache,queue_provider,shared_store(a type-keyed DI container),environment. Do not create your own DB pool, HTTP server, or job queue. use loco_rs::prelude::*;at the top of controllers/models/workers/tasks brings in the common types (AppContext,Result,Routes,Json,State, the Sea-ORM traits, JWT auth extractors underauth, etc.). If a common type is "missing", it is almost always in the prelude.Result<T>isloco_rs::Result<T>andErrorisloco_rs::Error(#[non_exhaustive]— match with a_ =>arm;EnvVar/Hash/SemVer/TaskJoinErrorwere removed). Use?; don't invent your own error enum for app code.- Config is YAML per-environment in
config/*.yaml, read throughctx.config. Don't read env vars ad hoc; use the config +get_envTera helper inside the YAML.
Project layout
src/
app.rs # Hooks impl: registers routes, workers, tasks, etc.
lib.rs / main.rs / bin/
controllers/ # HTTP handlers, grouped into Routes
models/
_entities/ # Sea-ORM entities (generated; don't hand-edit)
*.rs # your model logic (ActiveModel hooks, finders)
views/ # response shaping (JSON/HTML)
workers/ # background jobs
tasks/ # one-off / CLI tasks
mailers/ # email
initializers/ # startup hooks
migration/ # Sea-ORM migrations (separate crate)
config/ # development.yaml, production.yaml, test.yaml
tests/ # request/model/task tests
assets/ frontend/ # static assets / SPA (optional)
The App type implements the Hooks trait in src/app.rs. That is where you
register routes, workers, tasks, and initializers — a newly generated
controller/worker/task is not active until it is wired in there (the generators
do this for you). Hooks::boot's second parameter is environment: &Environment (the enum), not &str — copy from generated code, not memory.
Models & migrations (Sea-ORM 2.0)
- Generate:
cargo loco generate model posts title:string! content:text user:references. This writes a migration and regenerates the entity. - Apply:
cargo loco db migrate; regenerate entities:cargo loco db entities. - Primary and foreign keys are 64-bit (
i64/ BIGINT) in 1.0. Theintfield type is alsoi64/BIGINT now (it wasi32pre-1.0) — match key types when relating tables.small_intstill maps toi16if you need it. - Entities live in
src/models/_entities/and are generated — put custom logic insrc/models/<name>.rs(e.g.ActiveModelBehavior, finders). - Query with Sea-ORM:
Entity::find_by_id(id).one(&ctx.db).await?,Entity::find().filter(Column::Field.eq(x)).all(&ctx.db).await?. Create/update viaActiveModel+.insert/.update/.save. For ad-hoc filters, prefer Loco'squery::condition()...build()DSL (eq,like,contains,is_in,date_range, ~18 ops) over hand-rollingConditions. - Pagination:
query::paginate(&ctx.db, Entity::find(), Some(condition), &pagination_query).await?→PageResponse { page, meta: PagerMeta { page, page_size, total_pages, total_items } }. - Sea-ORM 2.0 note: raw-
Statementexecution methods carry a_rawsuffix (execute_raw,query_one_raw,query_all_raw); most apps never touch these.
Controllers & routing
use loco_rs::prelude::*;
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
format::json(Entity::find().all(&ctx.db).await?)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/posts/")
.add("/", get(list))
.add("{id}", get(get_one))
.add("/", post(add))
}
- Handlers are
async fn(State(ctx): State<AppContext>, ...) -> Result<Response>. Extract a body withJson(params): Json<Params>, a path withPath(id): Path<i64>, query withQuery(...). - Build a
Routesgroup with.prefix(...)+.add(path, method(handler))and return it fromroutes(); register it inapp.rsHooks::routes. - Shape responses with
format::json(...),format::html(...), or the view layer. Validate request bodies with theJsonValidateextractor +validatorderive. Errors map to HTTP:NotFound→404,Unauthorized→401,BadRequest/Validation→400, everything else (DB, IO, etc.)→500.
Authentication (auth, default feature)
- Feature is named
auth. Default signing algorithm is HS512;auth.jwt.secretmust be valid base64 — plain strings fail at token-generate/validate time, not config-load time. - Extractors:
auth::JWT(claims only, no DB needed),auth::JWTWithUser<T>(claims + loaded user, needswith-db),auth::ApiToken<T>(bearer API key → user, needswith-db; always reads theAuthorization: Bearerheader regardless ofauth.jwt.location). JWTWithUser/ApiTokenrequire your user model to implementloco_rs::model::Authenticable(find_by_api_key,find_by_claims_key).- Password hashing:
loco_rs::hash::{hash_password, verify_password, random_string}(Argon2id, always compiled).
Background workers (with priority)
use loco_rs::prelude::*;
pub struct DownloadWorker;
#[async_trait]
impl BackgroundWorker<DownloadWorkerArgs> for DownloadWorker {
fn build(ctx: &AppContext) -> Self { Self }
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> { Ok(()) }
}
// enqueue (returns the job id):
let job_id = DownloadWorker::perform_later(&ctx, args).await?;
// enqueue at a priority (higher runs first), on ANY backend:
DownloadWorker::perform_later_with_priority(&ctx, args, Some(100)).await?;
- Backends: Postgres and SQLite ship by default (feature
worker); Redis needs theworker_redisfeature. The backend is chosen at runtime (configworkers.mode+queue.kind). Register workers inapp.rsHooks::connect_workers. perform_later/perform_later_with_priorityreturn the job id (Result<String>). Priority (fulli32range) works on all three backends. Redis fully supports job admin now (cancel/clear/requeue/dump/ import) — it is not Postgres/SQLite-only.- Manage from the CLI:
cargo loco jobs cancel|tidy|purge|dump|import|requeue.
Scheduler, mailers, tasks
- Scheduler: cron-like jobs in
config/*.yamlunderscheduler:; run withcargo loco schedulerorcargo loco start --scheduler. Jobs run shell commands or registered tasks. - Mailers: generate with
cargo loco generate mailer; send withMailer::mail/mail_template. Templates live undersrc/mailers/<name>/. Configure SMTP TLS explicitly —mailer.smtp.tls: starttls|implicit|noneoverrides the legacysecurebool; implicit TLS / port 465 (SMTPS) needstls: implicit, sincesecure: truealone only ever means STARTTLS. - Tasks: implement the
Tasktrait; run withcargo loco task <name>. Great for admin/data operations that needAppContext.
Configuration
config/development.yaml, production.yaml, test.yaml. Selected by
LOCO_ENV → RAILS_ENV → NODE_ENV → development. {env}.local.yaml
overrides {env}.yaml when both exist. Access through ctx.config. Secrets
come from the environment via the get_env Tera helper inside the YAML,
e.g. password: "{{ get_env(name='SMTP_PASSWORD') }}". Don't scatter
std::env::var calls through app code.
Testing
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn can_list() {
request::<App>(|request, _ctx| async move { // NOTE: ::<App>, not ::<App, _, _>
let res = request.get("/api/posts/").await;
assert_eq!(res.status_code(), 200);
})
.await;
}
- Requires the
testingfeature (off by default in a plain lib dep, on for the generated app's dev-dependencies). - Request helpers take a callback
|request, ctx| async move { ... }— callrequest::<App, _, _>(...). Boot helperboot_test::<H>()is single-generic (notboot_test::<App, Migrator>()). - Use
request_with_create_db::<App, _, _>(...)for DB-backed tests (fresh DB, auto-cleaned), seed with fixtures, and snapshot withinsta(usetesting::redaction::cleanup_user_model()/cleanup_email()filters).#[serial]DB tests that share state.
Common LLM pitfalls (avoid these)
- ❌ Adding
axum,sqlx,tokio,lettre, a job runner, etc. directly and wiring a server by hand. ✅ They're already integrated behind Loco — usectxand the generators. - ❌ Hand-writing entities in
_entities/. ✅ Generate via migrations. - ❌
i32primary keys /Path<i32>, or assumingintfields are 32-bit. ✅i64everywhere in 1.0 (keys, FKs, and theintfield type). - ❌
boot_test::<App, Migrator>()(the old two-generic boot helper). ✅boot_test::<H>()in 1.0. - ❌ Building routes without registering them in
app.rs. ✅ ReturnRoutesfromroutes()and register inHooks::routes. - ❌ Custom error types for handlers. ✅ Return
loco_rs::Result<Response>and use?; matchErrorwith a_ =>arm (it's#[non_exhaustive]). - ❌ Reading env vars directly. ✅ YAML config +
ctx.config+get_env. - ❌ Assuming
secure: truecovers implicit TLS. ✅ Usetls: implicitfor port 465. - ❌
scaffold/controllergeneration without a kind flag. ✅ pass one of--api/--html/--htmx— there's no default.
The CLI you will use most
cargo loco start [--server-and-worker | --worker | --scheduler | --all]
cargo loco generate model|scaffold|controller|worker|task|scheduler|mailer|
migration|deployment|override [--api|--html|--htmx]
cargo loco db migrate|entities|reset|seed
cargo loco task <name>
cargo loco jobs cancel|tidy|purge|dump|import|requeue
cargo loco routes # list all routes
cargo loco doctor # check environment / versions
loco new (the separate app-generator binary, cargo install loco) flags:
--name --db <sqlite|postgres|none> --bg <async|queue|blocking> --assets <serverside|clientside|none> --os <linux|windows|macos> --allow-in-git-repo.
There is no --template/--verbose flag — template choice is
interactive-only.
When unsure, run cargo loco generate <thing> --help and read the produced code
— it is the canonical, up-to-date pattern.