# Project Structure & How to Scale It This is a [Loco](https://loco.rs) 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: 1. **`loco generate` just works.** Scaffolding lands in the right place; you never hand-move files. (This is exactly why the project moved *back* to this layout in the `loco straucture` commit.) 2. **Each layer has one reason to change.** A routing change touches only `controllers/`. A schema change touches `migration/` + `models/_entities/`. A "make the price display differently" change touches only `views/`. Bugs stay contained. 3. **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`: ```rust 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 { 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: ```rust 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/.rs` (+ register `routes()` in `app.rs`) | | A new admin screen | `controllers/admin_.rs` (prefix!) + `assets/views/admin/...` | | A new database table | `cargo loco generate model ...` → migration + `_entities` + wrapper | | A schema change to an existing table | `cargo loco generate migration ...`, then rebuild & migrate | | Business logic / a custom query | a method in `models/.rs` (not the controller) | | Reshaping data for a template | `views/.rs` | | An HTML template / partial | `assets/views//...html` | | A reusable helper (money, slugs, auth) | `shared/.rs` | | Something slow (resize image, send batch) | `workers/.rs` (+ register in `app.rs` `connect_workers`) | | A transactional email | `mailers/.rs` + `mailers///{subject,html,text}.t` | | One-time-at-boot setup / seeding | `initializers/.rs` (+ register in `app.rs` `initializers`) | | A CLI maintenance command | `tasks/.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: ```rust 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>> { 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: 1. **Keep controllers thin.** They query, gather, and render. Logic goes to `models/`, shaping goes to `views/`. If a handler exceeds ~80 lines, extract. 2. **One controller file per feature area, prefix-grouped.** Don't let `admin_products.rs` start handling orders. Split by area, not convenience. 3. **Never edit `models/_entities/`.** Change the schema via a migration and regenerate. Your logic in the sibling wrapper survives. 4. **Push slow/optional work to `workers/`.** Image processing, bulk emails, external API calls — off the request path so pages stay fast. 5. **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`. 6. **Every schema change is a migration file**, never a manual DB edit — so `test`, `development`, and `production` stay reproducible from `config/*.yaml` + `migration/`. 7. **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.rs` into `controllers/shop/mod.rs` + `controllers/shop/{listing,detail,search}.rs`. Loco doesn't care; `mod.rs` just re-exports a `routes()`. 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 makes `loco generate` and 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.rs` registers 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.rs` folders — never by going back to vertical slices.