loco straucture
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
pub mod users;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod audit_logs;
|
|
||||||
24
src/app.rs
24
src/app.rs
@@ -16,8 +16,14 @@ use std::{path::Path, sync::Arc};
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
account, admin, cart, checkout, home, i18n, initializers, media,
|
controllers::{
|
||||||
models::_entities::users, shop, tasks, workers::downloader::DownloadWorker,
|
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;
|
pub struct App;
|
||||||
@@ -66,16 +72,16 @@ impl Hooks for App {
|
|||||||
.add_route(cart::routes())
|
.add_route(cart::routes())
|
||||||
.add_route(checkout::routes())
|
.add_route(checkout::routes())
|
||||||
// cross-cutting
|
// cross-cutting
|
||||||
.add_route(account::routes())
|
.add_route(auth::routes())
|
||||||
.add_route(i18n::routes())
|
.add_route(i18n::routes())
|
||||||
.add_route(media::routes())
|
.add_route(media::routes())
|
||||||
// admin
|
// admin
|
||||||
.add_route(admin::routes())
|
.add_route(admin_dashboard::routes())
|
||||||
.add_route(admin::login::routes())
|
.add_route(admin_login::routes())
|
||||||
.add_route(admin::products::routes())
|
.add_route(admin_products::routes())
|
||||||
.add_route(admin::categories::routes())
|
.add_route(admin_categories::routes())
|
||||||
.add_route(admin::orders::routes())
|
.add_route(admin_orders::routes())
|
||||||
.add_route(admin::shipping::routes())
|
.add_route(admin_shipping::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod order_items;
|
|
||||||
pub mod orders;
|
|
||||||
pub mod shipping_methods;
|
|
||||||
@@ -11,14 +11,16 @@ use sea_orm::{
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
controllers::{
|
||||||
i18n::current_lang,
|
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||||
media::IMAGE_MAX_BYTES,
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
slug::{slugify, unique_slug},
|
slug::{slugify, unique_slug},
|
||||||
},
|
},
|
||||||
shop::models::{categories, products},
|
models::{categories, products},
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
//! Admin area. Each surface lives in its own submodule; this module holds the
|
//! Admin dashboard (HTML home + JSON stats).
|
||||||
//! 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;
|
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -15,7 +6,7 @@ use sea_orm::{EntityTrait, PaginatorTrait};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct DashboardResponse {
|
struct DashboardResponse {
|
||||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
|||||||
use axum::extract::Multipart;
|
use axum::extract::Multipart;
|
||||||
use loco_rs::prelude::*;
|
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<String>) -> Option<String> {
|
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||||
value.and_then(|value| {
|
value.and_then(|value| {
|
||||||
@@ -6,8 +6,9 @@ use loco_rs::prelude::*;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account::{self as auth_controller, models::users::{self, LoginParams}},
|
controllers::auth as auth_controller,
|
||||||
i18n::current_lang,
|
models::users::{self, LoginParams},
|
||||||
|
controllers::i18n::current_lang,
|
||||||
shared::guard,
|
shared::guard,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -7,11 +7,9 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
checkout::{
|
models::{order_items, orders},
|
||||||
models::{order_items, orders},
|
views::checkout as view,
|
||||||
view,
|
controllers::i18n::current_lang,
|
||||||
},
|
|
||||||
i18n::current_lang,
|
|
||||||
shared::{guard, settings},
|
shared::{guard, settings},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -10,18 +10,18 @@ use sea_orm::{
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
controllers::{
|
||||||
i18n::current_lang,
|
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||||
media::IMAGE_MAX_BYTES,
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::parse_price_to_cents,
|
money::parse_price_to_cents,
|
||||||
slug::{slugify, unique_slug},
|
slug::{slugify, unique_slug},
|
||||||
},
|
},
|
||||||
shop::{
|
models::{categories, product_images, products},
|
||||||
models::{categories, product_images, products},
|
views::shop as view,
|
||||||
view,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||||
@@ -7,8 +7,8 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
checkout::models::shipping_methods,
|
models::shipping_methods,
|
||||||
i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_price, parse_price_to_cents},
|
money::{format_price, parse_price_to_cents},
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
pub mod models;
|
|
||||||
pub mod view;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account::models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
account::view::{CurrentResponse, LoginResponse},
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
shared::guard::is_admin,
|
shared::guard::is_admin,
|
||||||
};
|
};
|
||||||
@@ -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 axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
@@ -8,18 +8,12 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::Duration as TimeDuration;
|
use time::Duration as TimeDuration;
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
pub mod view;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cart::{resolve_cart, CART_COOKIE},
|
controllers::cart::{resolve_cart, CART_COOKIE},
|
||||||
checkout::models::{
|
models::{order_items, orders, shipping_methods},
|
||||||
order_items,
|
controllers::i18n::current_lang,
|
||||||
orders::{self, Checkout},
|
|
||||||
shipping_methods,
|
|
||||||
},
|
|
||||||
i18n::current_lang,
|
|
||||||
shared::{money::format_price, settings},
|
shared::{money::format_price, settings},
|
||||||
|
views::checkout as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||||
@@ -145,7 +139,7 @@ async fn place_order(
|
|||||||
let order = orders::place(
|
let order = orders::place(
|
||||||
&ctx,
|
&ctx,
|
||||||
&valid,
|
&valid,
|
||||||
Checkout {
|
orders::Checkout {
|
||||||
email,
|
email,
|
||||||
customer_name: trimmed(&form.customer_name),
|
customer_name: trimmed(&form.customer_name),
|
||||||
address: trimmed(&form.address),
|
address: trimmed(&form.address),
|
||||||
@@ -4,7 +4,7 @@ use axum_extra::extract::cookie::CookieJar;
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{i18n::current_lang, shared::guard, shop};
|
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn index(
|
async fn index(
|
||||||
14
src/controllers/mod.rs
Normal file
14
src/controllers/mod.rs
Normal file
@@ -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;
|
||||||
@@ -6,13 +6,11 @@ use loco_rs::prelude::*;
|
|||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
pub mod view;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::guard,
|
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.
|
/// Shape a list of products into card rows, loading each one's primary image.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
use crate::account::models::users::{self, RegisterParams};
|
use crate::models::users::{self, RegisterParams};
|
||||||
|
|
||||||
pub struct AdminSeeder;
|
pub struct AdminSeeder;
|
||||||
|
|
||||||
|
|||||||
20
src/lib.rs
20
src/lib.rs
@@ -1,22 +1,10 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod controllers;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod initializers;
|
pub mod initializers;
|
||||||
pub mod mailers;
|
pub mod mailers;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod tasks;
|
|
||||||
pub mod workers;
|
|
||||||
|
|
||||||
// Cross-cutting helpers shared by every feature.
|
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
|
pub mod tasks;
|
||||||
// Feature slices: each owns its routes, handlers, view-shaping and the model
|
pub mod views;
|
||||||
// methods/services specific to it. Generated sea-orm entities stay shared in
|
pub mod workers;
|
||||||
// `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;
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::account::models::users;
|
use crate::models::users;
|
||||||
|
|
||||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||||
|
|||||||
@@ -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
|
//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit).
|
||||||
//! a unit, so they live here centrally. The hand-written model methods,
|
//! The sibling files contain hand-written model impls: ActiveModelBehavior,
|
||||||
//! services and view-shaping that use them live in the feature slices
|
//! finder methods, business logic, and query helpers.
|
||||||
//! (`shop::models`, `checkout::models`, `account::models`, …).
|
|
||||||
pub mod _entities;
|
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;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
use crate::account::models::users;
|
use crate::models::users;
|
||||||
use crate::account::AUTH_COOKIE;
|
use crate::controllers::auth::AUTH_COOKIE;
|
||||||
use crate::shared::settings;
|
use crate::shared::settings;
|
||||||
|
|
||||||
/// Is `user` the configured admin (settings.admin_email)?
|
/// Is `user` the configured admin (settings.admin_email)?
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod categories;
|
|
||||||
pub mod product_images;
|
|
||||||
pub mod product_product_tags;
|
|
||||||
pub mod product_tags;
|
|
||||||
pub mod products;
|
|
||||||
5
src/views/mod.rs
Normal file
5
src/views/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! JSON view-shaping structs for API responses and templates.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod checkout;
|
||||||
|
pub mod shop;
|
||||||
281
structure.md
Normal file
281
structure.md
Normal file
@@ -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<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:
|
||||||
|
|
||||||
|
```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/<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:
|
||||||
|
|
||||||
|
```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<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.
|
||||||
@@ -4,7 +4,7 @@ use loco_rs::testing::prelude::*;
|
|||||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use gitara_web::{
|
use gitara_web::{
|
||||||
account::models::users::{self, Model, RegisterParams},
|
models::users::{self, Model, RegisterParams},
|
||||||
app::App,
|
app::App,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings};
|
|||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use gitara_web::{account::models::users, app::App};
|
use gitara_web::{models::users, app::App};
|
||||||
|
|
||||||
use super::prepare_data;
|
use super::prepare_data;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::http::{HeaderName, HeaderValue};
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
use loco_rs::{app::AppContext, TestServer};
|
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_EMAIL: &str = "test@loco.com";
|
||||||
const USER_PASSWORD: &str = "1234";
|
const USER_PASSWORD: &str = "1234";
|
||||||
|
|||||||
Reference in New Issue
Block a user