Files
kompress_eshop/structure.md
2026-06-17 09:58:36 +02:00

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:

  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:

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:

  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.