12 KiB
Project Structure & How to Scale It
This is a Loco app (Rust, on top of Axum + SeaORM). It uses the standard Loco layer layout. This document explains why that layout scales and how you add things as the shop grows, so you never have to guess where a new piece of code belongs.
1. The mental model: layers, not features
Loco organizes code by what kind of thing it is (a layer), not by which
feature it belongs to. The top-level dirs under src/ are the layers:
src/
├── app.rs # The wiring hub: registers routes, workers, initializers, tasks
├── lib.rs # Declares which modules exist (pub mod ...)
├── bin/main.rs # Binary entrypoint (you rarely touch this)
│
├── controllers/ # HTTP layer: routes + request handlers
├── models/ # Data layer: DB entities + business logic
│ └── _entities/ # AUTO-GENERATED SeaORM structs — never hand-edit
├── views/ # Presentation layer: shapes data into JSON for templates
├── mailers/ # Email sending + email templates (.t files)
├── workers/ # Background jobs (async, off the request path)
├── tasks/ # CLI tasks (`cargo loco task ...`)
├── initializers/ # Runs once at boot (seeders, view engine setup, ...)
├── fixtures/ # Seed data (YAML) for `cargo loco db seed`
├── data/ # Misc static/loaded data
└── shared/ # Cross-cutting helpers used by many layers
Supporting dirs outside src/:
migration/ # SeaORM migrations (one file per schema change)
config/ # development.yaml / test.yaml / production.yaml
assets/ # Tera templates (views/), i18n (.ftl), static files, CSS
tests/ # requests/ models/ workers/ tasks/ + snapshot .snap files
Why layers scale
The instinct is often "put everything for the shop in one folder." That feels nice early, but it fights the framework: Loco's codegen, conventions, and docs all assume layers. By staying with layers you get:
loco generatejust works. Scaffolding lands in the right place; you never hand-move files. (This is exactly why the project moved back to this layout in theloco straucturecommit.)- Each layer has one reason to change. A routing change touches only
controllers/. A schema change touchesmigration/+models/_entities/. A "make the price display differently" change touches onlyviews/. Bugs stay contained. - New contributors (and AI tools) navigate by convention, not by reading the whole tree.
The trade-off — "I have to open 3 dirs to see the whole shop feature" — is solved below with naming, not folders.
2. Feature grouping without folders: the naming convention
You still get the "everything for X in one glance" benefit, via filename
prefixes inside the flat controllers/ dir:
controllers/
├── home.rs ┐
├── shop.rs │ public storefront
├── cart.rs │
├── checkout.rs ┘
│
├── admin_dashboard.rs ┐
├── admin_products.rs │
├── admin_categories.rs│ admin area — `admin_` prefix groups them
├── admin_orders.rs │
├── admin_shipping.rs │
├── admin_login.rs │
├── admin_form.rs ┘
│
├── auth.rs ┐
├── i18n.rs │ cross-cutting
└── media.rs ┘
In your editor's file list, admin_* sorts together — you see the whole admin
surface at once, but loco generate and Loco conventions still see flat
controllers. Best of both.
Rule of thumb: prefix = the "feature area." Add admin_returns.rs, not a
returns/ folder.
3. How a request flows through the layers
Trace the shop index (GET /shop) to see how layers cooperate — this is the
pattern every feature follows:
Browser → app.rs routes() # 1. router dispatches /shop to shop::index
→ controllers/shop.rs # 2. handler: query DB, gather data
→ models/products.rs # 3. data layer: products::Entity::find()...
→ views/shop.rs # 4. shape Model → JSON (product_card)
→ shared/guard.rs # 5. cross-cutting: is admin logged in?
→ assets/views/shop/index.html # 6. Tera renders the JSON
→ HTML response
Concretely, from controllers/shop.rs:
use crate::{
models::{categories, product_images, products}, // data layer
views::shop as view, // presentation layer
shared::guard, // cross-cutting
controllers::i18n::current_lang,
};
async fn index(...) -> Result<Response> {
let list = products::Entity::find() // query (models)
.filter(products::Column::Published.eq(true))
.all(&ctx.db).await?;
format::view(&v, "shop/index.html", json!({ // render
"products": product_rows(&ctx, list).await?, // shaped by views::shop
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}))
}
The controller is a thin coordinator. It does not contain business logic —
that lives in models/. It does not build HTML strings — that's views/ +
templates. Keeping controllers thin is the single biggest factor in whether this
stays scalable.
4. The models layer: the one piece of Loco that surprises people
There are two files per database table, and they have different jobs:
models/
├── _entities/products.rs ← AUTO-GENERATED. The raw table struct
│ (columns, relations). Regenerated as a unit
│ whenever the schema changes. NEVER hand-edit.
│
└── products.rs ← HAND-WRITTEN. Re-exports the entity, then adds
your behavior on top of it.
Your models/products.rs shows the pattern exactly:
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
// lifecycle hooks, e.g. touch updated_at before save
}
impl Model {} // read-oriented logic (your finders that return data)
impl ActiveModel {} // write-oriented logic (validation, mutation)
impl Entity {} // custom queries / selectors
Why two files: the schema is machine-owned (so codegen can overwrite
_entities/ safely), but your logic is human-owned (so it survives
regeneration). The pub use bridge means the rest of the app imports
crate::models::products and never has to know _entities exists.
How to apply when scaling: put domain logic on the model, not in the
controller. "Is this product low on stock?" → a method on products::Model.
"Recalculate order total" → a method on orders. As features pile up, this is
what keeps controllers from turning back into the 900-line god-files this
project deliberately escaped.
5. Where new code goes — a decision table
When you build the next thing, find the row and follow it:
| You want to add... | Touch these |
|---|---|
| A new page / endpoint | controllers/<area>.rs (+ register routes() in app.rs) |
| A new admin screen | controllers/admin_<thing>.rs (prefix!) + assets/views/admin/... |
| A new database table | cargo loco generate model <name> ... → migration + _entities + wrapper |
| A schema change to an existing table | cargo loco generate migration <name> ..., then rebuild & migrate |
| Business logic / a custom query | a method in models/<entity>.rs (not the controller) |
| Reshaping data for a template | views/<area>.rs |
| An HTML template / partial | assets/views/<area>/...html |
| A reusable helper (money, slugs, auth) | shared/<helper>.rs |
| Something slow (resize image, send batch) | workers/<name>.rs (+ register in app.rs connect_workers) |
| A transactional email | mailers/<name>.rs + mailers/<name>/<event>/{subject,html,text}.t |
| One-time-at-boot setup / seeding | initializers/<name>.rs (+ register in app.rs initializers) |
| A CLI maintenance command | tasks/<name>.rs (+ register in app.rs register_tasks) |
| A cross-cutting config value | shared/settings.rs + config/*.yaml |
6. app.rs is the wiring hub — the one file you revisit constantly
Every new route, worker, initializer, and task is registered here. It's the table of contents for the whole backend:
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.add_route(shop::routes()) // ← every new controller's routes()
.add_route(admin_products::routes())// gets one line here
// ...
}
async fn initializers(...) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![ /* AdminSeeder, ShippingSeeder, ViewEngine ... */ ])
}
async fn connect_workers(...) { queue.register(DownloadWorker::build(ctx)).await?; }
fn register_tasks(tasks: &mut Tasks) { /* tasks-inject */ }
If you add a controller and the route 404s, the usual cause is: you forgot the
add_route line in app.rs. Same shape for workers/initializers/tasks.
7. Scaling checklist (the habits that keep this healthy)
As the shop grows, these are the things that decide whether the codebase stays pleasant or rots:
- Keep controllers thin. They query, gather, and render. Logic goes to
models/, shaping goes toviews/. If a handler exceeds ~80 lines, extract. - One controller file per feature area, prefix-grouped. Don't let
admin_products.rsstart handling orders. Split by area, not convenience. - Never edit
models/_entities/. Change the schema via a migration and regenerate. Your logic in the sibling wrapper survives. - Push slow/optional work to
workers/. Image processing, bulk emails, external API calls — off the request path so pages stay fast. - Reuse via
shared/and model methods, not copy-paste. You already do this well:money(integer cents everywhere),guard(one source of truth for admin auth),slug. - Every schema change is a migration file, never a manual DB edit — so
test,development, andproductionstay reproducible fromconfig/*.yaml+migration/. - Mirror new code with a test in
tests/{models,requests,...}/. The snapshot tests (.snap) catch accidental output changes for free.
When a feature genuinely outgrows a single file
If one area gets huge (say shop becomes 5+ concerns), you have two
Loco-friendly options — both keep the layout intact:
- Split by sub-area with more prefixes:
shop_catalog.rs,shop_search.rs,shop_reviews.rs. - Promote a layer file to a folder module: turn
controllers/shop.rsintocontrollers/shop/mod.rs+controllers/shop/{listing,detail,search}.rs. Loco doesn't care;mod.rsjust re-exports aroutes().
What you should not do is recreate top-level vertical slices (a src/shop/
holding its own controllers+models+views). That's the layout this project
already tried and reverted — it breaks loco generate and fights the framework.
TL;DR
- Layers, not features.
controllers/ models/ views/ ...is deliberate and is what makesloco generateand Loco conventions work for you. - Group features by filename prefix (
admin_*) inside the flat layers. - Controllers are thin coordinators; logic lives on models, shaping in views.
_entities/is machine-owned; the sibling model file is yours.app.rsregisters everything — add a line there for each new route/worker/task.- Scale by adding files within layers, splitting busy files into more
prefixes or
mod.rsfolders — never by going back to vertical slices.