From b88c990873731f1db7a1eb905b96354a32ca8ef5 Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 16 Jun 2026 23:40:53 +0200 Subject: [PATCH] loco straucture --- src/account/models/mod.rs | 1 - src/admin/models/mod.rs | 1 - src/app.rs | 24 +- src/checkout/models/mod.rs | 3 - .../admin_categories.rs} | 10 +- .../mod.rs => controllers/admin_dashboard.rs} | 13 +- .../form.rs => controllers/admin_form.rs} | 2 +- .../login.rs => controllers/admin_login.rs} | 5 +- .../orders.rs => controllers/admin_orders.rs} | 8 +- .../admin_products.rs} | 14 +- .../admin_shipping.rs} | 4 +- src/{account/mod.rs => controllers/auth.rs} | 7 +- src/{cart/mod.rs => controllers/cart.rs} | 2 +- .../mod.rs => controllers/checkout.rs} | 16 +- src/{home/mod.rs => controllers/home.rs} | 2 +- src/{i18n/mod.rs => controllers/i18n.rs} | 0 src/{media/mod.rs => controllers/media.rs} | 0 src/controllers/mod.rs | 14 + src/{shop/mod.rs => controllers/shop.rs} | 8 +- src/initializers/admin_seeder.rs | 2 +- src/lib.rs | 20 +- src/mailers/auth.rs | 2 +- src/{admin => }/models/audit_logs.rs | 0 src/{shop => }/models/categories.rs | 0 src/models/mod.rs | 21 +- src/{checkout => }/models/order_items.rs | 0 src/{checkout => }/models/orders.rs | 0 src/{shop => }/models/product_images.rs | 0 src/{shop => }/models/product_product_tags.rs | 0 src/{shop => }/models/product_tags.rs | 0 src/{shop => }/models/products.rs | 0 src/{checkout => }/models/shipping_methods.rs | 0 src/{account => }/models/users.rs | 0 src/shared/guard.rs | 4 +- src/shop/models/mod.rs | 5 - src/{account/view.rs => views/auth.rs} | 0 src/{checkout/view.rs => views/checkout.rs} | 0 src/views/mod.rs | 5 + src/{shop/view.rs => views/shop.rs} | 0 structure.md | 281 ++++++++++++++++++ tests/models/users.rs | 2 +- tests/requests/auth.rs | 2 +- tests/requests/prepare_data.rs | 2 +- 43 files changed, 378 insertions(+), 102 deletions(-) delete mode 100644 src/account/models/mod.rs delete mode 100644 src/admin/models/mod.rs delete mode 100644 src/checkout/models/mod.rs rename src/{admin/categories.rs => controllers/admin_categories.rs} (97%) rename src/{admin/mod.rs => controllers/admin_dashboard.rs} (81%) rename src/{admin/form.rs => controllers/admin_form.rs} (96%) rename src/{admin/login.rs => controllers/admin_login.rs} (94%) rename src/{admin/orders.rs => controllers/admin_orders.rs} (96%) rename src/{admin/products.rs => controllers/admin_products.rs} (97%) rename src/{admin/shipping.rs => controllers/admin_shipping.rs} (96%) rename src/{account/mod.rs => controllers/auth.rs} (98%) rename src/{cart/mod.rs => controllers/cart.rs} (98%) rename src/{checkout/mod.rs => controllers/checkout.rs} (96%) rename src/{home/mod.rs => controllers/home.rs} (88%) rename src/{i18n/mod.rs => controllers/i18n.rs} (100%) rename src/{media/mod.rs => controllers/media.rs} (100%) create mode 100644 src/controllers/mod.rs rename src/{shop/mod.rs => controllers/shop.rs} (97%) rename src/{admin => }/models/audit_logs.rs (100%) rename src/{shop => }/models/categories.rs (100%) rename src/{checkout => }/models/order_items.rs (100%) rename src/{checkout => }/models/orders.rs (100%) rename src/{shop => }/models/product_images.rs (100%) rename src/{shop => }/models/product_product_tags.rs (100%) rename src/{shop => }/models/product_tags.rs (100%) rename src/{shop => }/models/products.rs (100%) rename src/{checkout => }/models/shipping_methods.rs (100%) rename src/{account => }/models/users.rs (100%) delete mode 100644 src/shop/models/mod.rs rename src/{account/view.rs => views/auth.rs} (100%) rename src/{checkout/view.rs => views/checkout.rs} (100%) create mode 100644 src/views/mod.rs rename src/{shop/view.rs => views/shop.rs} (100%) create mode 100644 structure.md diff --git a/src/account/models/mod.rs b/src/account/models/mod.rs deleted file mode 100644 index 913bd46..0000000 --- a/src/account/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod users; diff --git a/src/admin/models/mod.rs b/src/admin/models/mod.rs deleted file mode 100644 index 45b0b44..0000000 --- a/src/admin/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod audit_logs; diff --git a/src/app.rs b/src/app.rs index df645df..fd84398 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,8 +16,14 @@ use std::{path::Path, sync::Arc}; #[allow(unused_imports)] use crate::{ - account, admin, cart, checkout, home, i18n, initializers, media, - models::_entities::users, shop, tasks, workers::downloader::DownloadWorker, + controllers::{ + admin_categories, admin_dashboard, admin_form, admin_login, admin_orders, + admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop, + }, + initializers, + models::_entities::users, + tasks, + workers::downloader::DownloadWorker, }; pub struct App; @@ -66,16 +72,16 @@ impl Hooks for App { .add_route(cart::routes()) .add_route(checkout::routes()) // cross-cutting - .add_route(account::routes()) + .add_route(auth::routes()) .add_route(i18n::routes()) .add_route(media::routes()) // admin - .add_route(admin::routes()) - .add_route(admin::login::routes()) - .add_route(admin::products::routes()) - .add_route(admin::categories::routes()) - .add_route(admin::orders::routes()) - .add_route(admin::shipping::routes()) + .add_route(admin_dashboard::routes()) + .add_route(admin_login::routes()) + .add_route(admin_products::routes()) + .add_route(admin_categories::routes()) + .add_route(admin_orders::routes()) + .add_route(admin_shipping::routes()) } async fn after_context(ctx: AppContext) -> Result { diff --git a/src/checkout/models/mod.rs b/src/checkout/models/mod.rs deleted file mode 100644 index 6048c01..0000000 --- a/src/checkout/models/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod order_items; -pub mod orders; -pub mod shipping_methods; diff --git a/src/admin/categories.rs b/src/controllers/admin_categories.rs similarity index 97% rename from src/admin/categories.rs rename to src/controllers/admin_categories.rs index 4186ee1..8d11121 100644 --- a/src/admin/categories.rs +++ b/src/controllers/admin_categories.rs @@ -11,14 +11,16 @@ use sea_orm::{ use serde_json::json; use crate::{ - admin::form::{read_multipart_form, store_image, MultipartForm}, - i18n::current_lang, - media::IMAGE_MAX_BYTES, + controllers::{ + admin_form::{read_multipart_form, store_image, MultipartForm}, + i18n::current_lang, + media::IMAGE_MAX_BYTES, + }, shared::{ guard, slug::{slugify, unique_slug}, }, - shop::models::{categories, products}, + models::{categories, products}, }; async fn category_by_id(ctx: &AppContext, id: i32) -> Result { diff --git a/src/admin/mod.rs b/src/controllers/admin_dashboard.rs similarity index 81% rename from src/admin/mod.rs rename to src/controllers/admin_dashboard.rs index 27c2cbc..994aec8 100644 --- a/src/admin/mod.rs +++ b/src/controllers/admin_dashboard.rs @@ -1,13 +1,4 @@ -//! Admin area. Each surface lives in its own submodule; this module holds the -//! dashboard (HTML home + JSON stats) and is the entry point for admin routes. - -pub mod categories; -pub mod form; -pub mod login; -pub mod models; -pub mod orders; -pub mod products; -pub mod shipping; +//! Admin dashboard (HTML home + JSON stats). use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; @@ -15,7 +6,7 @@ use sea_orm::{EntityTrait, PaginatorTrait}; use serde::Serialize; use serde_json::json; -use crate::{i18n::current_lang, models::_entities, shared::guard}; +use crate::{controllers::i18n::current_lang, models::_entities, shared::guard}; #[derive(Debug, Serialize)] struct DashboardResponse { diff --git a/src/admin/form.rs b/src/controllers/admin_form.rs similarity index 96% rename from src/admin/form.rs rename to src/controllers/admin_form.rs index 2b8c3e1..20acdd0 100644 --- a/src/admin/form.rs +++ b/src/controllers/admin_form.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use axum::extract::Multipart; use loco_rs::prelude::*; -use crate::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR}; +use crate::controllers::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR}; fn normalize_empty(value: Option) -> Option { value.and_then(|value| { diff --git a/src/admin/login.rs b/src/controllers/admin_login.rs similarity index 94% rename from src/admin/login.rs rename to src/controllers/admin_login.rs index c9438a7..6c49bcb 100644 --- a/src/admin/login.rs +++ b/src/controllers/admin_login.rs @@ -6,8 +6,9 @@ use loco_rs::prelude::*; use serde_json::json; use crate::{ - account::{self as auth_controller, models::users::{self, LoginParams}}, - i18n::current_lang, + controllers::auth as auth_controller, + models::users::{self, LoginParams}, + controllers::i18n::current_lang, shared::guard, }; diff --git a/src/admin/orders.rs b/src/controllers/admin_orders.rs similarity index 96% rename from src/admin/orders.rs rename to src/controllers/admin_orders.rs index acbd91e..9259b3a 100644 --- a/src/admin/orders.rs +++ b/src/controllers/admin_orders.rs @@ -7,11 +7,9 @@ use serde::Deserialize; use serde_json::json; use crate::{ - checkout::{ - models::{order_items, orders}, - view, - }, - i18n::current_lang, + models::{order_items, orders}, + views::checkout as view, + controllers::i18n::current_lang, shared::{guard, settings}, }; diff --git a/src/admin/products.rs b/src/controllers/admin_products.rs similarity index 97% rename from src/admin/products.rs rename to src/controllers/admin_products.rs index a7089ec..860bff8 100644 --- a/src/admin/products.rs +++ b/src/controllers/admin_products.rs @@ -10,18 +10,18 @@ use sea_orm::{ use serde_json::json; use crate::{ - admin::form::{read_multipart_form, store_image, MultipartForm}, - i18n::current_lang, - media::IMAGE_MAX_BYTES, + controllers::{ + admin_form::{read_multipart_form, store_image, MultipartForm}, + i18n::current_lang, + media::IMAGE_MAX_BYTES, + }, shared::{ guard, money::parse_price_to_cents, slug::{slugify, unique_slug}, }, - shop::{ - models::{categories, product_images, products}, - view, - }, + models::{categories, product_images, products}, + views::shop as view, }; async fn product_by_id(ctx: &AppContext, id: i32) -> Result { diff --git a/src/admin/shipping.rs b/src/controllers/admin_shipping.rs similarity index 96% rename from src/admin/shipping.rs rename to src/controllers/admin_shipping.rs index 717ff8e..0bc2c39 100644 --- a/src/admin/shipping.rs +++ b/src/controllers/admin_shipping.rs @@ -7,8 +7,8 @@ use serde::Deserialize; use serde_json::json; use crate::{ - checkout::models::shipping_methods, - i18n::current_lang, + models::shipping_methods, + controllers::i18n::current_lang, shared::{ guard, money::{format_price, parse_price_to_cents}, diff --git a/src/account/mod.rs b/src/controllers/auth.rs similarity index 98% rename from src/account/mod.rs rename to src/controllers/auth.rs index 614983f..3758d4d 100644 --- a/src/account/mod.rs +++ b/src/controllers/auth.rs @@ -1,9 +1,6 @@ -pub mod models; -pub mod view; - use crate::{ - account::models::users::{self, LoginParams, RegisterParams}, - account::view::{CurrentResponse, LoginResponse}, + models::users::{self, LoginParams, RegisterParams}, + views::auth::{CurrentResponse, LoginResponse}, mailers::auth::AuthMailer, shared::guard::is_admin, }; diff --git a/src/cart/mod.rs b/src/controllers/cart.rs similarity index 98% rename from src/cart/mod.rs rename to src/controllers/cart.rs index b726751..5407f66 100644 --- a/src/cart/mod.rs +++ b/src/controllers/cart.rs @@ -1,4 +1,4 @@ -use crate::{i18n::current_lang, shared::money::format_price, shop::models::products}; +use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; diff --git a/src/checkout/mod.rs b/src/controllers/checkout.rs similarity index 96% rename from src/checkout/mod.rs rename to src/controllers/checkout.rs index 340a8b7..eb72b53 100644 --- a/src/checkout/mod.rs +++ b/src/controllers/checkout.rs @@ -8,18 +8,12 @@ use serde::Deserialize; use serde_json::json; use time::Duration as TimeDuration; -pub mod models; -pub mod view; - use crate::{ - cart::{resolve_cart, CART_COOKIE}, - checkout::models::{ - order_items, - orders::{self, Checkout}, - shipping_methods, - }, - i18n::current_lang, + controllers::cart::{resolve_cart, CART_COOKIE}, + models::{order_items, orders, shipping_methods}, + controllers::i18n::current_lang, shared::{money::format_price, settings}, + views::checkout as view, }; const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"]; @@ -145,7 +139,7 @@ async fn place_order( let order = orders::place( &ctx, &valid, - Checkout { + orders::Checkout { email, customer_name: trimmed(&form.customer_name), address: trimmed(&form.address), diff --git a/src/home/mod.rs b/src/controllers/home.rs similarity index 88% rename from src/home/mod.rs rename to src/controllers/home.rs index 7faa885..9eb71e9 100644 --- a/src/home/mod.rs +++ b/src/controllers/home.rs @@ -4,7 +4,7 @@ use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use serde_json::json; -use crate::{i18n::current_lang, shared::guard, shop}; +use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop}; #[debug_handler] async fn index( diff --git a/src/i18n/mod.rs b/src/controllers/i18n.rs similarity index 100% rename from src/i18n/mod.rs rename to src/controllers/i18n.rs diff --git a/src/media/mod.rs b/src/controllers/media.rs similarity index 100% rename from src/media/mod.rs rename to src/controllers/media.rs diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs new file mode 100644 index 0000000..479c95d --- /dev/null +++ b/src/controllers/mod.rs @@ -0,0 +1,14 @@ +pub mod auth; +pub mod admin_categories; +pub mod admin_dashboard; +pub mod admin_form; +pub mod admin_login; +pub mod admin_orders; +pub mod admin_products; +pub mod admin_shipping; +pub mod cart; +pub mod checkout; +pub mod home; +pub mod i18n; +pub mod media; +pub mod shop; diff --git a/src/shop/mod.rs b/src/controllers/shop.rs similarity index 97% rename from src/shop/mod.rs rename to src/controllers/shop.rs index b5166ea..669c82f 100644 --- a/src/shop/mod.rs +++ b/src/controllers/shop.rs @@ -6,13 +6,11 @@ use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set}; use serde_json::json; -pub mod models; -pub mod view; - use crate::{ - i18n::current_lang, + controllers::i18n::current_lang, shared::guard, - shop::models::{categories, product_images, products}, + models::{categories, product_images, products}, + views::shop as view, }; /// Shape a list of products into card rows, loading each one's primary image. diff --git a/src/initializers/admin_seeder.rs b/src/initializers/admin_seeder.rs index 810079e..b4d4620 100644 --- a/src/initializers/admin_seeder.rs +++ b/src/initializers/admin_seeder.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use loco_rs::prelude::*; -use crate::account::models::users::{self, RegisterParams}; +use crate::models::users::{self, RegisterParams}; pub struct AdminSeeder; diff --git a/src/lib.rs b/src/lib.rs index 3a9f39f..1ab61e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,22 +1,10 @@ pub mod app; +pub mod controllers; pub mod data; pub mod initializers; pub mod mailers; pub mod models; -pub mod tasks; -pub mod workers; - -// Cross-cutting helpers shared by every feature. pub mod shared; - -// Feature slices: each owns its routes, handlers, view-shaping and the model -// methods/services specific to it. Generated sea-orm entities stay shared in -// `models::_entities`. -pub mod account; -pub mod admin; -pub mod cart; -pub mod checkout; -pub mod home; -pub mod i18n; -pub mod media; -pub mod shop; +pub mod tasks; +pub mod views; +pub mod workers; diff --git a/src/mailers/auth.rs b/src/mailers/auth.rs index ca385dd..88b949a 100644 --- a/src/mailers/auth.rs +++ b/src/mailers/auth.rs @@ -4,7 +4,7 @@ use loco_rs::prelude::*; use serde_json::json; -use crate::account::models::users; +use crate::models::users; static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); diff --git a/src/admin/models/audit_logs.rs b/src/models/audit_logs.rs similarity index 100% rename from src/admin/models/audit_logs.rs rename to src/models/audit_logs.rs diff --git a/src/shop/models/categories.rs b/src/models/categories.rs similarity index 100% rename from src/shop/models/categories.rs rename to src/models/categories.rs diff --git a/src/models/mod.rs b/src/models/mod.rs index 7386ed0..206f25d 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,7 +1,18 @@ -//! Shared data layer: the sea-orm entities generated by `loco generate`. +//! Shared data layer: SeaORM entities and their hand-written model extensions. //! -//! These structs cross-reference each other (relations) and are regenerated as -//! a unit, so they live here centrally. The hand-written model methods, -//! services and view-shaping that use them live in the feature slices -//! (`shop::models`, `checkout::models`, `account::models`, …). +//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit). +//! The sibling files contain hand-written model impls: ActiveModelBehavior, +//! finder methods, business logic, and query helpers. + pub mod _entities; + +pub mod audit_logs; +pub mod categories; +pub mod order_items; +pub mod orders; +pub mod product_images; +pub mod product_product_tags; +pub mod product_tags; +pub mod products; +pub mod shipping_methods; +pub mod users; diff --git a/src/checkout/models/order_items.rs b/src/models/order_items.rs similarity index 100% rename from src/checkout/models/order_items.rs rename to src/models/order_items.rs diff --git a/src/checkout/models/orders.rs b/src/models/orders.rs similarity index 100% rename from src/checkout/models/orders.rs rename to src/models/orders.rs diff --git a/src/shop/models/product_images.rs b/src/models/product_images.rs similarity index 100% rename from src/shop/models/product_images.rs rename to src/models/product_images.rs diff --git a/src/shop/models/product_product_tags.rs b/src/models/product_product_tags.rs similarity index 100% rename from src/shop/models/product_product_tags.rs rename to src/models/product_product_tags.rs diff --git a/src/shop/models/product_tags.rs b/src/models/product_tags.rs similarity index 100% rename from src/shop/models/product_tags.rs rename to src/models/product_tags.rs diff --git a/src/shop/models/products.rs b/src/models/products.rs similarity index 100% rename from src/shop/models/products.rs rename to src/models/products.rs diff --git a/src/checkout/models/shipping_methods.rs b/src/models/shipping_methods.rs similarity index 100% rename from src/checkout/models/shipping_methods.rs rename to src/models/shipping_methods.rs diff --git a/src/account/models/users.rs b/src/models/users.rs similarity index 100% rename from src/account/models/users.rs rename to src/models/users.rs diff --git a/src/shared/guard.rs b/src/shared/guard.rs index aab8b4f..db9d32f 100644 --- a/src/shared/guard.rs +++ b/src/shared/guard.rs @@ -3,8 +3,8 @@ use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; -use crate::account::models::users; -use crate::account::AUTH_COOKIE; +use crate::models::users; +use crate::controllers::auth::AUTH_COOKIE; use crate::shared::settings; /// Is `user` the configured admin (settings.admin_email)? diff --git a/src/shop/models/mod.rs b/src/shop/models/mod.rs deleted file mode 100644 index c9071d3..0000000 --- a/src/shop/models/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod categories; -pub mod product_images; -pub mod product_product_tags; -pub mod product_tags; -pub mod products; diff --git a/src/account/view.rs b/src/views/auth.rs similarity index 100% rename from src/account/view.rs rename to src/views/auth.rs diff --git a/src/checkout/view.rs b/src/views/checkout.rs similarity index 100% rename from src/checkout/view.rs rename to src/views/checkout.rs diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..67afebd --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,5 @@ +//! JSON view-shaping structs for API responses and templates. + +pub mod auth; +pub mod checkout; +pub mod shop; diff --git a/src/shop/view.rs b/src/views/shop.rs similarity index 100% rename from src/shop/view.rs rename to src/views/shop.rs diff --git a/structure.md b/structure.md new file mode 100644 index 0000000..c691546 --- /dev/null +++ b/structure.md @@ -0,0 +1,281 @@ +# 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. diff --git a/tests/models/users.rs b/tests/models/users.rs index 6584ed9..fd8399b 100644 --- a/tests/models/users.rs +++ b/tests/models/users.rs @@ -4,7 +4,7 @@ use loco_rs::testing::prelude::*; use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; use serial_test::serial; use gitara_web::{ - account::models::users::{self, Model, RegisterParams}, + models::users::{self, Model, RegisterParams}, app::App, }; diff --git a/tests/requests/auth.rs b/tests/requests/auth.rs index 7e318c4..023e9d2 100644 --- a/tests/requests/auth.rs +++ b/tests/requests/auth.rs @@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings}; use loco_rs::testing::prelude::*; use rstest::rstest; use serial_test::serial; -use gitara_web::{account::models::users, app::App}; +use gitara_web::{models::users, app::App}; use super::prepare_data; diff --git a/tests/requests/prepare_data.rs b/tests/requests/prepare_data.rs index edf8b37..57174f7 100644 --- a/tests/requests/prepare_data.rs +++ b/tests/requests/prepare_data.rs @@ -1,6 +1,6 @@ use axum::http::{HeaderName, HeaderValue}; use loco_rs::{app::AppContext, TestServer}; -use gitara_web::{account::models::users, account::view::LoginResponse}; +use gitara_web::{models::users, views::auth::LoginResponse}; const USER_EMAIL: &str = "test@loco.com"; const USER_PASSWORD: &str = "1234";