Danh sách bài viết

Bài 17: Project Structure Cho Axum App

Bài 17 của series Rust RESTful API — refactor cấu trúc folder crate shop-api từ monolithic main.rs sau B16 (6 route gồm / + /health + /version + 3 demo error + 4 handler + helper build_router + shutdown_signal + bootstrap dồn một file ~100 dòng) sang hybrid layer-based + feature-folder, chuẩn bị scale cho G3+ thêm 10+ route resource mà không phình main.rs thành 500 dòng; tạo state.rs với AppState { config: Arc<AppConfig> } clone-cheap qua Arc internal share giữa handler qua State<AppState> extractor (B28), sẽ extend pool: PgPool G6 + redis G18 + tracer handle G15; tạo routes/ folder layer infrastructure với 3 file resource routes/health.rs + routes/version.rs + routes/demo_error.rs mỗi file export pub fn routes() -> Router<AppState> build sub-router; tạo router.rs aggregate qua Router::merge(routes::<name>::routes()) + with_state(state) provide AppState toàn router; tạo app.rs bootstrap function pub async fn run() -> anyhow::Result<()> đóng gói init telemetry + load AppConfig + bind TcpListener + axum::serve với graceful shutdown từ B12; refactor main.rs còn ~10 dòng thin entry point chỉ mod app; mod router; mod routes; mod state; mod handlers; mod responses; mod middleware; mod extractors; mod dto; #[tokio::main] async fn main() -> anyhow::Result<()> { app::run().await }; tạo 5 placeholder folders handlers/ + responses/ + middleware/ + extractors/ + dto/ với mod.rs rỗng chuẩn bị populate G3+/B32/B40/G15/B41; so sánh trade-off layer-based (classic MVC, dễ navigate "find all handlers", spread feature qua 4 folder) vs feature-folder (vertical slice, isolation per feature, cross-feature common phải tách riêng) — Shop API chọn hybrid vì domain vừa (60 endpoints) feature-folder thuần dư + cross-resource code (state, response, error) cần share natural ở layer; verify cargo run -p shop-api vẫn hoạt động end-to-end y hệt B16 với cùng 6 endpoint cùng behavior.

12/06/2026
11 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu hai cách tổ chức folder cho Rust web app — layer-based (theo concern: routes/handlers/services/models) vs feature-folder (vertical slice theo domain: product/order/cart, mỗi feature một folder gói trọn route + handler + dto + service).
  • Biết tradeoff cụ thể mỗi cách — layer-based dễ navigate "find all handlers" nhưng spread một feature qua 4 folder; feature-folder isolation tốt nhưng cross-feature common code phải tách riêng.
  • Refactor crates/shop-api/src/main.rs monolithic sau B16 (~100 dòng) ra routes/ (sub-router per resource), handlers/ (placeholder), responses/ (placeholder), state.rs (AppState struct), router.rs (build_router master), app.rs (bootstrap + serve + shutdown).
  • Hiểu Rust module system với mod, pub, mod.rs vs file module — cách compiler resolve module path, visibility rule cross-module trong cùng crate.
  • Hiểu pattern AppState clone-cheap qua Arc internal — share giữa nhiều handler concurrent không clone heap data, chuẩn bị cho State<AppState> extractor B28.
  • Sau B17: main.rs chỉ ~10 dòng (declare module + #[tokio::main] gọi app::run().await), bootstrap logic chuyển sang app.rs, router build chuyển sang router.rs.
  • Sẵn sàng cho G3+ thêm nhiều route resource (Product, Order, Cart, User, Review) qua pattern routes::<name>::routes() mà không phình main.rs.
2

Hiện Trạng shop-api/main.rs Đã Phình

Sau B16, file crates/shop-api/src/main.rs đã chứa quá nhiều thứ trong một chỗ. Liệt kê đầy đủ:

  • 6 route/, /health, /version (B12 production-ready) + /error/not-found, /error/unauthenticated, /error/rate-limited (B16 demo).
  • 4 handler functionroot(), health(), version(), cộng 3 demo handler demo_not_found(), demo_unauthenticated(), demo_rate_limited().
  • Helper build_router() — gom tất cả 6 route vào một Router.
  • Helper shutdown_signal() — đóng gói pattern bắt SIGINT + SIGTERM (B12).
  • main() bootstrap — init tracing, load AppConfig, bind TcpListener, axum::serve với graceful shutdown, log lifecycle.

Tổng cộng ~100 dòng cho một crate API mới chạy 6 endpoint. Vấn đề khi mở rộng dễ thấy:

  • G3+ (Routing Cơ Bản, B21-B30) sẽ thêm khoảng 10 route mới demo (path parameter, query, nested route, fallback). Mỗi route khoảng 8-15 dòng. Tổng thêm 100-150 dòng.
  • G7 (CRUD Cơ Bản, B61-B70) thêm khoảng 20 route CRUD cho Product. Tổng thêm 300-400 dòng.
  • G11+ thêm Auth, Cart, Order, Admin... ước tính 60 endpoint cuối series.
  • main.rs sẽ phình lên 500-800 dòng nếu không refactor — không ai navigate nổi, conflict merge liên tục khi nhiều dev thêm route song song.

Trước khi G3 bắt đầu thêm route, refactor structure phải làm ngay bây giờ. Càng để lâu, refactor càng đắt vì phải sửa nhiều handler đã viết.

Folder tree hiện tại (trước B17):

crates/shop-api/src/
└── main.rs            (~100 dòng — 6 route + 4 handler + helper + bootstrap)
3

2 Approach: Layer-Based vs Feature-Folder

Cộng đồng Rust web (axum, actix-web) có hai pattern tổ chức folder phổ biến. Cần hiểu kỹ trade-off trước khi quyết định.

Approach 1 — Layer-Based (kiểu MVC kinh điển, học từ Rails/Django/Spring). Folder theo concern (lớp kiến trúc):

src/
├── routes/         (mọi route definition)
│   ├── product.rs  (build sub-router cho /products)
│   ├── order.rs
│   └── cart.rs
├── handlers/       (mọi handler implementation)
│   ├── product.rs  (list_products, get_product, create_product, ...)
│   ├── order.rs
│   └── cart.rs
├── services/       (business logic)
│   ├── product.rs
│   ├── order.rs
│   └── cart.rs
└── models/         (entity + DTO)
    ├── product.rs
    ├── order.rs
    └── cart.rs

Pros của layer-based:

  • Dễ navigate theo concern — "find all handlers" mở folder handlers/ thấy hết, không phải scan toàn project.
  • Clear separation of concerns — junior dev mới onboard dễ hiểu lớp: route nhận request, handler validate + gọi service, service chứa business logic, model là data shape.
  • Cross-cutting concern dễ tìm — middleware, response type, error map đều ở layer riêng, không vương trong feature folder.

Cons của layer-based:

  • Một feature spread qua 4 folder — sửa "endpoint create product" phải mở routes/product.rs để xem route, handlers/product.rs để sửa handler, services/product.rs để sửa logic, models/product.rs để sửa DTO. Mỗi feature change là 4 file diff.
  • Khó delete/move feature — xóa "review" phải xóa 4 file ở 4 folder khác nhau, dễ sót.

Approach 2 — Feature-Folder (vertical slice, phổ biến trong DDD + microservice). Folder theo domain (feature business):

src/
├── product/
│   ├── mod.rs        (re-export)
│   ├── routes.rs     (build sub-router cho /products)
│   ├── handlers.rs   (list_products, get_product, ...)
│   ├── dto.rs        (ProductDto, CreateProductDto, ...)
│   └── service.rs    (ProductService)
├── order/
│   ├── mod.rs
│   ├── routes.rs
│   ├── handlers.rs
│   ├── dto.rs
│   └── service.rs
└── cart/ ...

Pros của feature-folder:

  • Feature isolation — sửa "endpoint create product" mở folder product/, sửa 2-3 file kề nhau. Mental load thấp.
  • Dễ delete/move — xóa "review" xóa folder review/ là xong (trừ reference cross-feature).
  • Modular tốt cho microservice migration — folder feature dễ tách thành crate riêng nếu domain phình lớn.

Cons của feature-folder:

  • Cross-feature common code phải tách riêngAppState, AppError, custom response, middleware không thuộc feature nào — phải đặt ở folder common riêng. Cuối cùng vẫn có lớp infrastructure tách rời.
  • Khó "find all handlers" — không có một chỗ duy nhất xem mọi handler — phải scan từng feature folder.
  • Risk duplication — junior dev không biết common có gì → copy-paste helper qua nhiều feature folder.

Bảng so sánh ngắn:

Tiêu chí                     | Layer-Based      | Feature-Folder
-----------------------------|------------------|----------------
Find-by-concern              | Dễ              | Khó
Find-by-feature              | Khó             | Dễ
Onboard junior dev           | Dễ hơn          | Cần biết domain
Spread feature change        | 4 file/folder   | 2-3 file kề nhau
Delete feature               | Khó (4 chỗ)     | Dễ (1 folder)
Cross-cutting code           | Tự nhiên ở layer | Phải tách common
Phù hợp project nhỏ          | Vừa             | Hơi dư
Phù hợp project lớn          | Phình routes/   | Scale tốt
4

Decision Cho Shop API: Hybrid Layered + Feature

Shop API chọn hybrid approach: layer-based cho infrastructure + feature-aware cho route/handler/dto per resource. Rationale 3 điểm:

  • Shop API là size vừa — ước tính 60 endpoint cuối series (đã lock plan), phân bổ vào ~10 resource (Product, Category, Cart, Order, User, Review, Payment, Address, Notification, Admin). Feature-folder thuần với mỗi feature một folder 4 file là dư — over-engineering. Layer-based với routes/ + handlers/ chứa 10 file là vừa, mỗi file 30-80 dòng đọc dễ.
  • Cross-resource infrastructure cần shared naturalAppState share giữa mọi handler, AppError map response chung, custom response envelope, middleware (auth, rate-limit, request-id), custom extractor (CurrentUser). Tách layer riêng (state.rs, responses/, middleware/, extractors/) cleaner, không vương trong từng feature folder.
  • Domain code (business logic) đẩy về crate riêngshop-core (init G4) chứa entity + service + repository trait, không nằm trong shop-api. shop-api chỉ là HTTP layer — routing + handler validate + gọi service. Pattern này là Clean Architecture / Hexagonal: tách HTTP concern khỏi domain. Vì domain ra ngoài, shop-api tự nhiên gọn không cần feature-folder full.

Folder structure final cho B17 (mới + future placeholder cho bài sau):

crates/shop-api/src/
├── main.rs               (~10 dòng, thin entry point — chỉ mod declare + main fn)
├── app.rs                (bootstrap: init telemetry + load config + listen + serve + shutdown)
├── router.rs             (build_router master — aggregate sub-routers via merge)
├── state.rs              (AppState struct, sẽ extend khi G6/G18)
├── routes/               (route function per resource — layer infrastructure)
│   ├── mod.rs            (pub mod health; pub mod version; pub mod demo_error;)
│   ├── health.rs         (move from main.rs)
│   ├── version.rs        (move from main.rs)
│   └── demo_error.rs     (move from main.rs — sẽ remove khi G3)
├── handlers/             (handler impl per resource — placeholder, populate G3+)
│   └── mod.rs            (rỗng comment placeholder)
├── responses/            (shared response types, custom IntoResponse — populate B40/B41)
│   └── mod.rs            (rỗng comment placeholder)
├── middleware/           (custom middleware — populate G15)
│   └── mod.rs            (rỗng comment placeholder)
├── extractors/           (custom extractors như CurrentUser — populate B32)
│   └── mod.rs            (rỗng comment placeholder)
└── dto/                  (DTO per resource — populate B41)
    └── mod.rs            (rỗng comment placeholder)

Quan sát thiết kế:

  • app.rs + router.rs + state.rs là file đơn lẻ ở root src/ (không folder) — mỗi concern infrastructure level chỉ một file đủ. state.rs chứa một struct nên không cần folder; router.rs chứa một function aggregate; app.rs chứa bootstrap function.
  • routes/ + handlers/ + responses/ + middleware/ + extractors/ + dto/ là folder vì sẽ chứa nhiều file resource. Mỗi folder có mod.rs aggregate.
  • 5 placeholder folder (handlers, responses, middleware, extractors, dto) tạo mod.rs rỗng ngay B17 — lý do: khi B32 thêm extractors/current_user.rs hoặc B41 thêm dto/product.rs, không cần tạo lại folder + cập nhật main.rs, chỉ thêm file + thêm dòng pub mod trong mod.rs. Pre-allocate "shelf" sẵn sàng đón module mới.

Decision lock cho phần còn lại series: pattern này áp dụng vĩnh viễn cho shop-api. Không đổi qua giai đoạn mở rộng. Khi G3+ thêm Product handler thì:

  • Route Product → crates/shop-api/src/routes/product.rs với pub fn routes() -> Router<AppState>.
  • Handler Product → crates/shop-api/src/handlers/product.rs với list_products, get_product, create_product, ...
  • DTO Product → crates/shop-api/src/dto/product.rs với ProductDto, CreateProductDto.
  • router.rs thêm dòng .merge(routes::product::routes())build_router.
5

Step 1: Tạo state.rs Cho AppState

Bắt đầu refactor bằng file đơn giản nhất — định nghĩa AppState. Đây là struct share giữa mọi handler qua extractor State<AppState> (deep dive B28).

Tạo file mới crates/shop-api/src/state.rs:

// File: crates/shop-api/src/state.rs
use shop_common::config::AppConfig;
use std::sync::Arc;

/// AppState — shared state cross-handler.
///
/// Pattern lock từ B17 — clone-cheap qua Arc internal, share giữa nhiều
/// handler concurrent qua extractor `State<AppState>` (B28).
///
/// Sẽ extend khi: G6 thêm `pool: PgPool` (PostgreSQL), G18 thêm
/// `redis: RedisPool`, G15 thêm tracer handle cho observability.
#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    // pub pool: PgPool,      // G6 — sqlx PostgreSQL pool
    // pub redis: RedisPool,  // G18 — fred Redis pool
}

impl AppState {
    pub fn new(config: AppConfig) -> Self {
        Self {
            config: Arc::new(config),
        }
    }
}

Phân tích thiết kế:

  • #[derive(Clone)] bắt buộc — axum yêu cầu AppState: Clone để extractor State<AppState> hoạt động. Mỗi request handler nhận một clone của state. Vì field bên trong wrap qua Arc, clone chỉ tăng reference count (atomic increment) chứ không deep-copy AppConfig heap data — clone giá rẻ nanoseconds.
  • config: Arc<AppConfig>AppConfig đã có từ B10 (shop_common::config::AppConfig) với field app_env, port, database_url, redis_url, jwt_secret. Wrap qua Arc share read-only giữa nhiều handler. Pattern Arc<T> cho immutable shared data là idiomatic Rust async — không lock, không clone heap.
  • Field comment placeholder cho future — note rõ pool: PgPool sẽ thêm G6, redis: RedisPool G18. Sub-agent đọc note này không lăn tăn khi sửa state.rs về sau.
  • Method new(config) — constructor đơn giản, nhận AppConfig owned (move-in), wrap qua Arc. Caller (sẽ thấy ở app.rs) gọi AppState::new(config).

Tại sao tách state.rs ra file riêng thay vì để trong app.rs? Hai lý do:

  • Cross-module import naturalroutes/health.rs, routes/version.rs, router.rs đều import qua use crate::state::AppState;. Đặt state ở chỗ trung lập (không nested trong app) làm cross-module import sạch.
  • Module sẽ phình lớn khi G6/G18 extend — thêm field pool, redis kèm helper constructor riêng. File riêng dễ scale.
6

Step 2: Tách routes/ Folder

Tạo folder crates/shop-api/src/routes/ với mod.rs aggregate + 3 file resource. Move 4 handler từ main.rs sang.

File crates/shop-api/src/routes/mod.rs:

// File: crates/shop-api/src/routes/mod.rs

pub mod demo_error;
pub mod health;
pub mod version;

File crates/shop-api/src/routes/health.rs:

// File: crates/shop-api/src/routes/health.rs
use axum::{routing::get, Json, Router};
use serde_json::json;

use crate::state::AppState;

/// Build sub-router cho `/health` endpoint.
///
/// Sẽ phân tách thành `/healthz` (liveness) + `/readyz` (readiness check
/// DB + Redis) ở G15 — lúc đó file này chứa cả hai route + helper check.
pub fn routes() -> Router<AppState> {
    Router::new().route("/health", get(health))
}

async fn health() -> Json<serde_json::Value> {
    Json(json!({ "status": "ok" }))
}

File crates/shop-api/src/routes/version.rs:

// File: crates/shop-api/src/routes/version.rs
use axum::{routing::get, Json, Router};
use serde_json::json;

use crate::state::AppState;

/// Build sub-router cho `/version` endpoint.
///
/// Trả build metadata (name + version + edition) cho debug deploy.
/// Sẽ extend ở G15 thêm `commit_sha` (vergen crate) + `build_time`.
pub fn routes() -> Router<AppState> {
    Router::new().route("/version", get(version))
}

async fn version() -> Json<serde_json::Value> {
    Json(json!({
        "name": "shop-api",
        "version": env!("CARGO_PKG_VERSION"),
        "edition": "2024",
    }))
}

File crates/shop-api/src/routes/demo_error.rs (move 3 demo handler từ B16):

// File: crates/shop-api/src/routes/demo_error.rs
use axum::{routing::get, Json, Router};
use shop_common::error::{AppError, AppResult};

use crate::state::AppState;

/// Build sub-router cho 3 demo error endpoint (B16).
///
/// TẠM THỜI — verify `impl IntoResponse for AppError` end-to-end. Sẽ
/// REMOVE toàn bộ file này khi G3+ có handler resource thật (Product,
/// User, ...). Lúc đó dòng `.merge(routes::demo_error::routes())` trong
/// `router.rs` cũng xóa luôn + `pub mod demo_error;` ở `mod.rs` xóa.
pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/error/not-found", get(demo_not_found))
        .route("/error/unauthenticated", get(demo_unauthenticated))
        .route("/error/rate-limited", get(demo_rate_limited))
}

async fn demo_not_found() -> AppResult<Json<serde_json::Value>> {
    Err(AppError::NotFound("product 'phone-x' not found".to_string()))
}

async fn demo_unauthenticated() -> AppResult<Json<serde_json::Value>> {
    Err(AppError::Unauthenticated)
}

async fn demo_rate_limited() -> AppResult<Json<serde_json::Value>> {
    Err(AppError::RateLimited(30))
}

Quan sát pattern lock cho mọi route module Shop API:

  • Export pub fn routes() -> Router<AppState> — mỗi file resource export một function tên routes(). Pattern này giữ router.rs aggregate gọn (routes::product::routes(), routes::order::routes(), ...). Tên function consistent cross-resource — không phải build_product_routes() chỗ này, product_router() chỗ kia.
  • Return type Router<AppState> — generic type parameter S trên Router<S> là kiểu state. Khi return Router<AppState> nghĩa là sub-router này yêu cầu AppState được provide sau cùng (qua with_staterouter.rs). Nếu thiếu AppState, axum không serve được — compile error rõ ràng.
  • Handler nội bộ là async fn private — không pub. Chỉ function routes() là public export. Handler là detail internal của module — encapsulation tốt.
  • Import use crate::state::AppState; — path qua crate (root của crate) reference module statesrc/state.rs. Pattern này lock cross-module trong cùng crate shop-api.
7

Step 3: router.rs Aggregate Tất Cả Route

Tạo file crates/shop-api/src/router.rs aggregate mọi sub-router + provide AppState:

// File: crates/shop-api/src/router.rs
use axum::{http::StatusCode, routing::get, Router};

use crate::routes;
use crate::state::AppState;

/// Build master router — gom mọi sub-router resource + provide AppState.
///
/// Pattern: mỗi file `routes/<resource>.rs` export `pub fn routes() ->
/// Router<AppState>` build sub-router; ở đây dùng `Router::merge` gộp
/// cùng cấp path, hoặc `Router::nest(prefix, sub)` khi cần prefix
/// (sẽ áp dụng `/api/v1` nest ở B24).
///
/// `with_state(state)` provide AppState cho mọi handler trong toàn router.
/// Sau call này, generic param `Router<AppState>` resolve thành `Router<()>`
/// — sẵn sàng để `axum::serve` consume.
pub fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/", get(root))
        .merge(routes::health::routes())
        .merge(routes::version::routes())
        .merge(routes::demo_error::routes())
        .with_state(state)
}

async fn root() -> (StatusCode, &'static str) {
    (StatusCode::OK, "shop-api v0.1.0")
}

Phân tích pattern aggregate:

  • Router::merge(sub_router) — gộp sub-router cùng cấp path không thêm prefix. routes::health::routes() trả Router chứa route /health → sau merge router master có /health ở root level. Khác với nest("/api/v1", sub) sẽ thêm prefix /api/v1 vào mọi route trong sub (deep dive B24).
  • Route / đặt trực tiếp trong build_router — banner endpoint quá đơn giản, không đáng tách file riêng. Pattern: route đơn lẻ "không thuộc resource nào" giữ ở router.rs hoặc tách thành routes/root.rs tùy preference. Shop API chọn giữ / trong router.rs cho gọn.
  • with_state(state) ở cuối chain — sau khi gộp xong mọi sub-router (mỗi sub-router kiểu Router<AppState>), with_state(state) consume state ownership và "fix" generic param. Kết quả là Router<()> không còn cần state — sẵn sàng cho axum::serve(listener, router). Nếu thiếu with_state, compile error: "the trait Service is not implemented for Router<AppState>" — axum::serve cần state unit.
  • Handler root() — return type là tuple (StatusCode, &'static str) theo decision matrix B14 (banner endpoint trả text + status, không phải data endpoint nên không cần JSON envelope).

Trong tương lai G3+ thêm Product:

// Preview G7 — không paste vào B17:
pub fn build_router(state: AppState) -> Router {
    Router::new()
        .route("/", get(root))
        .merge(routes::health::routes())
        .merge(routes::version::routes())
        // Nest /api/v1 — đẩy nhiều resource vào một prefix (B24):
        .nest("/api/v1", Router::new()
            .merge(routes::product::routes())
            .merge(routes::order::routes())
            .merge(routes::cart::routes())
        )
        .with_state(state)
}

Pattern scale tốt: thêm resource = thêm một dòng .merge(...). router.rs luôn ngắn và đọc thấy ngay endpoint topology.

8

Step 4: app.rs Bootstrap Logic

Tạo file crates/shop-api/src/app.rs đóng gói bootstrap — phần lớn logic trước đây nằm trong main(). Bao gồm init telemetry, load config, build router, bind listener, serve với graceful shutdown.

// File: crates/shop-api/src/app.rs
use anyhow::Context;
use shop_common::{config::AppConfig, telemetry};
use tokio::signal;

use crate::{router, state::AppState};

/// Bootstrap shop-api binary — entry point chính, gọi từ `main.rs`.
///
/// Tách ra `app.rs` thay vì để trong `main.rs` để dễ test integration
/// (gọi `app::run()` trực tiếp từ test harness mà không phải spawn
/// process). Cũng cleaner cho future: thêm bước init metric exporter
/// (G15), DB pool (G6), Redis pool (G18) ở đây chứ không phình main.rs.
pub async fn run() -> anyhow::Result<()> {
    // Init structured logging trước — mọi log sau đây đi qua tracing layer.
    // `false` = pretty format (dev); production set true cho JSON format.
    telemetry::init_tracing(false);

    // Load AppConfig từ env variable (dotenvy + std::env::var).
    let config = AppConfig::from_env().context("load AppConfig from env")?;
    let port = config.port;

    // Build AppState — wrap config qua Arc clone-cheap.
    let state = AppState::new(config);

    // Build master router — aggregate mọi sub-router + provide state.
    let app = router::build_router(state);

    // Bind TCP listener trên 0.0.0.0:<port> — accept connection mọi interface.
    let addr = format!("0.0.0.0:{}", port);
    let listener = tokio::net::TcpListener::bind(&addr)
        .await
        .with_context(|| format!("bind TCP listener on {}", addr))?;
    tracing::info!(addr = %addr, "shop-api listening");

    // Serve với graceful shutdown (pattern lock từ B12).
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await
        .context("axum::serve fail")?;

    tracing::info!("shop-api stopped");
    Ok(())
}

/// Bắt SIGINT (Ctrl+C, cross-platform) + SIGTERM (Unix-only, K8s/Docker
/// gửi để stop graceful). Race hai future qua `tokio::select!` —
/// signal nào đến trước thì shutdown.
///
/// Pattern lock từ B12 áp dụng cho mọi binary workspace (`shop-api`
/// đang dùng, `shop-worker` G21, `shop-cli` G29).
async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }

    tracing::info!("shutdown signal received, draining requests");
}

Phân tích cấu trúc:

  • pub async fn run() -> anyhow::Result<()> — bootstrap function single entry, return anyhow::Result<()> để main propagate error qua ? operator. Error chain qua .context("...") giữ trace tốt cho log.
  • Order init quan trọng — telemetry trước (mọi log sau đi qua subscriber), config kế (cần env var), state, router, listener, serve. Đổi order có thể miss log lúc bootstrap. Pattern này lock vĩnh viễn cho mọi binary Shop API.
  • anyhow::Context qua .context("...").with_context(|| ...) — annotate mỗi step error với context cụ thể. Nếu bind fail, log thấy "bind TCP listener on 0.0.0.0:3000: Address already in use" thay vì chỉ "Address already in use". with_context(closure) lazy — chỉ format string khi error xảy ra (tiết kiệm khi happy path).
  • shutdown_signal() private — detail internal của bootstrap, không export. Pattern lock từ B12 (note "Graceful Shutdown Pattern" trong shop-state.md).
  • Future test integration — vì run() là async function, test harness có thể spawn task gọi app::run() + shutdown sau khi test xong. Trade-off: phải bind real socket — hoặc refactor thành build_app() -> Router để test gọi router trực tiếp qua axum-test (B253 sẽ lock pattern này).
9

Step 5: main.rs Thin Entry Point

Step cuối cùng — refactor crates/shop-api/src/main.rs từ ~100 dòng monolithic xuống ~12 dòng thin entry point.

// File: crates/shop-api/src/main.rs

// Infrastructure modules — file đơn lẻ, không folder
mod app;
mod router;
mod state;

// Resource layer modules — folder per concern
mod routes;

// Placeholder modules — folder rỗng, sẽ populate ở các bài sau
mod dto;          // DTO per resource (B41+)
mod extractors;   // Custom extractors như CurrentUser (B32+)
mod handlers;     // Handler impl per resource (G3+ khi resource thật)
mod middleware;   // Custom middleware (G15+)
mod responses;    // Shared response types, custom IntoResponse (B40+)

/// Thin entry point — toàn bộ logic ở `app::run()`.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    app::run().await
}

Quan sát thiết kế:

  • Mọi module declare ở đây qua mod ... — Rust compiler resolve mod app thành file src/app.rs, mod routes thành src/routes/mod.rs (vì là folder). Pattern này là Rust idiomatic — module path mirror file path.
  • Module không cần pub — chỉ mod xxx private trong crate root. Lý do: shop-api là binary crate, không expose API ra ngoài. Mọi cross-module reference dùng use crate::xxx trong cùng crate, không cần public visibility.
  • Placeholder mod declare ngaymod handlers; mod responses; mod middleware; mod extractors; mod dto;. 5 folder placeholder mỗi folder có mod.rs rỗng (sẽ tạo ở Step kế). Pre-allocate "shelf" giúp B32/B40/B41 không phải sửa main.rs khi thêm file mới — chỉ thêm dòng pub mod xxx; trong mod.rs của folder tương ứng.
  • main() chỉ một dòngapp::run().await — clean, dễ đọc, không có business logic. Pattern này cho phép unit test app::run() riêng nếu cần.

Tạo nội dung 5 file mod.rs placeholder. Tất cả cùng pattern — chỉ comment giải thích mục đích:

// File: crates/shop-api/src/handlers/mod.rs
//
// Handler implementations per resource — populate G3+ khi có handler
// resource thật (Product, Order, Cart, User). Pattern: `handlers/product.rs`
// chứa `list_products`, `get_product`, `create_product`, ...
//
// Placeholder rỗng — chưa có module con. Khi thêm file mới, thêm dòng
// `pub mod product;` (hoặc tương đương) vào đây.
// File: crates/shop-api/src/responses/mod.rs
//
// Shared response types + custom IntoResponse. Populate B40 (Response
// Builder Pattern) cho `ApiResponse<T>` wrapper envelope (nếu cần),
// B41 cho `JsonRejection` custom mapping → `AppError::BadRequest`.
//
// Note B16: `impl IntoResponse for AppError` đặt ở `shop-common::error`,
// KHÔNG ở folder này (orphan rule + share cross-binary).
// File: crates/shop-api/src/middleware/mod.rs
//
// Custom middleware Shop API — populate G15 (Observability + Production):
// request-id, structured access log, timing, panic catch.
// G17 thêm rate-limit middleware. G18 thêm auth-required middleware.
//
// Placeholder rỗng — sẽ thêm `pub mod request_id;`, `pub mod auth;`, ...
// File: crates/shop-api/src/extractors/mod.rs
//
// Custom extractors — populate B32 (CurrentUser extract từ JWT claim),
// B34 (Cookie extractor), B41 (ValidatedJson wrapper).
//
// Placeholder rỗng — sẽ thêm `pub mod current_user;`, `pub mod validated_json;`, ...
// File: crates/shop-api/src/dto/mod.rs
//
// DTO per resource — populate B41 (JSON Extract + Validation) khi DTO
// đầu tiên được implement thật. Pattern lock từ B15 (note "DTO Convention"
// trong shop-state.md): `#[derive(Debug, Clone, Serialize, Deserialize)]`,
// `#[serde(rename_all = "camelCase")]`, file path `dto/<resource>.rs`.
//
// Placeholder rỗng — sẽ thêm `pub mod product;`, `pub mod order;`, ...

5 file mỗi file ~5 dòng comment — không có code thực tế, chỉ note mục đích để sub-agent tương lai biết folder làm gì khi đến thời điểm populate.

10

Suggested Commit & Verify

Build và verify mọi endpoint vẫn hoạt động end-to-end y hệt B16:

cd shop
cargo build -p shop-api
# Compiling shop-api v0.1.0 (.../crates/shop-api)
#     Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.1s

cargo run -p shop-api
# 2026-06-12T10:00:00.123Z  INFO shop_api::app: shop-api listening addr=0.0.0.0:3000

Test 6 endpoint quen thuộc — không có endpoint nào thay đổi behavior:

curl -s http://localhost:3000/
# shop-api v0.1.0

curl -s http://localhost:3000/health
# {"status":"ok"}

curl -s http://localhost:3000/version
# {"edition":"2024","name":"shop-api","version":"0.1.0"}

curl -i http://localhost:3000/error/not-found
# HTTP/1.1 404 Not Found
# content-type: application/json
# {"code":"NOT_FOUND","error":"not found: product 'phone-x' not found","request_id":null}

curl -i http://localhost:3000/error/unauthenticated
# HTTP/1.1 401 Unauthorized
# www-authenticate: Bearer realm="shop-api"

curl -i http://localhost:3000/error/rate-limited
# HTTP/1.1 429 Too Many Requests
# retry-after: 30

Test Ctrl+C → server shutdown graceful:

# Ctrl+C trong terminal chạy server
# 2026-06-12T10:01:00.456Z  INFO shop_api::app: shutdown signal received, draining requests
# 2026-06-12T10:01:00.789Z  INFO shop_api::app: shop-api stopped

Mọi endpoint + behavior identical B16. Refactor B17 là zero-functional-change — chỉ tổ chức lại code structure.

Files thay đổi B17 (13 file mới + 1 file updated):

  • UPDATED: crates/shop-api/src/main.rs — slim từ ~100 dòng xuống ~12 dòng.
  • NEW: crates/shop-api/src/app.rs — bootstrap logic.
  • NEW: crates/shop-api/src/router.rs — build_router master.
  • NEW: crates/shop-api/src/state.rs — AppState struct.
  • NEW: crates/shop-api/src/routes/mod.rs — aggregate sub-router.
  • NEW: crates/shop-api/src/routes/health.rs — sub-router /health.
  • NEW: crates/shop-api/src/routes/version.rs — sub-router /version.
  • NEW: crates/shop-api/src/routes/demo_error.rs — sub-router 3 demo error.
  • NEW: crates/shop-api/src/handlers/mod.rs — placeholder.
  • NEW: crates/shop-api/src/responses/mod.rs — placeholder.
  • NEW: crates/shop-api/src/middleware/mod.rs — placeholder.
  • NEW: crates/shop-api/src/extractors/mod.rs — placeholder.
  • NEW: crates/shop-api/src/dto/mod.rs — placeholder.

Suggested commit message:

git add crates/shop-api/src/
git commit -m "B17: refactor shop-api ra app/router/routes/state — chuẩn bị scale"

Sau commit, codebase ready cho G3 (B21-B30 Routing Cơ Bản) thêm route mới qua pattern routes::<resource>::routes() không phình main.rs.

11

Tổng Kết

  • Hai approach tổ chức folder: layer-based (folder theo concern — routes/handlers/services/models) vs feature-folder (folder theo domain — product/order/cart, vertical slice gói trọn route + handler + dto + service).
  • Trade-off chính: layer-based dễ navigate "find all handlers" nhưng spread feature qua 4 folder; feature-folder isolation tốt nhưng cross-feature common code phải tách riêng + khó "find all handlers".
  • Shop API chọn hybrid — layer cho infrastructure (responses/, state.rs, router.rs, middleware/, extractors/) + feature-aware cho route/handler/dto per resource (routes/product.rs, handlers/product.rs, dto/product.rs). Rationale: size vừa 60 endpoint, cross-resource code cần share, domain logic đẩy về crate shop-core riêng.
  • Folder structure final: app.rs (bootstrap), router.rs (aggregate sub-router), state.rs (AppState), routes/<resource>.rs (sub-router per resource), 5 placeholder folder handlers/ + responses/ + middleware/ + extractors/ + dto/ cho future scale.
  • Pattern per-resource sub-router lock: mỗi file routes/<name>.rs export pub fn routes() -> Router<AppState> build sub-router với route + handler internal private; router.rs dùng Router::merge(routes::<name>::routes()) aggregate.
  • Router::merge(sub) gộp cùng cấp path không thêm prefix; Router::nest(prefix, sub) thêm prefix vào mọi route trong sub (deep dive B24 sẽ áp dụng nest("/api/v1", ...)).
  • with_state(state) ở cuối chain provide AppState cho mọi handler, resolve generic Router<AppState>Router<()> sẵn sàng cho axum::serve.
  • AppState wrap field qua Arc internal — Clone chỉ tăng reference count (atomic), share cross-handler concurrent không deep-copy heap. Pattern lock vĩnh viễn cho Shop API. Sẽ extend pool: PgPool G6, redis: RedisPool G18.
  • main.rs giờ thin ~12 dòng — chỉ mod ... declare + #[tokio::main] + app::run().await. Bootstrap logic chuyển sang app.rs dễ test integration future + thêm bước init (metric, pool) gọn.
  • 5 placeholder folder pre-allocate ngay B17 (mod.rs với comment giải thích mục đích) — B32/B40/B41/G3+/G15 thêm file mới chỉ cần thêm dòng pub mod ...; không phải tạo folder + sửa main.rs.
  • Refactor B17 là zero-functional-change — 6 endpoint cùng behavior identical B16, verify qua cargo run -p shop-api + curl. Chỉ structure thay đổi.
  • Sau B17 sẵn sàng cho G3+ thêm routes::product, routes::order, handlers::product, dto::product clean — pattern lock không thay đổi qua phần còn lại series.
12

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Phân biệt layer-based vs feature-folder approach tổ chức folder cho web app. Mỗi cách có trade-off gì cụ thể? Khi nào nên chọn cách nào?
  2. Shop API chọn approach hybrid. Cụ thể folder/file nào theo layer-based, folder/file nào theo feature? Tại sao chọn hybrid mà không phải pure layer-based hoặc pure feature-folder?
  3. Router::merge(sub_router)Router::nest(prefix, sub_router) khác nhau thế nào? Vd cụ thể: Router::new().merge(routes::product::routes()) với routes::product::routes() trả router có route /products — endpoint cuối là gì? So với Router::new().nest("/api/v1", routes::product::routes()) thì endpoint cuối là gì?
  4. AppState wrap field qua Arc internal. Tại sao cần Arc? Pattern share AppState cross-handler thế nào (qua extractor nào)? Vì sao AppState bắt buộc impl Clone?
  5. main.rs sau B17 chỉ 12 dòng. Bootstrap logic (init telemetry, load config, bind listener, serve) chuyển sang đâu? Function nào là entry point thực tế? Tại sao tách bootstrap khỏi main()?
Đáp án
  1. Layer-based tổ chức folder theo concern (kiến trúc layer): routes/, handlers/, services/, models/ — mỗi folder chứa file per resource (vd handlers/product.rs, handlers/order.rs). Pros: dễ "find all handlers" mở folder thấy hết, clear separation of concerns, onboard junior dễ. Cons: một feature change spread qua 4 folder (sửa "create product" mở 4 file ở 4 folder), khó delete feature (xóa 4 chỗ). Feature-folder (vertical slice) tổ chức folder theo domain: product/, order/, cart/ — mỗi folder chứa routes.rs + handlers.rs + dto.rs + service.rs kề nhau. Pros: feature isolation, sửa feature mở 2-3 file kề nhau mental load thấp, dễ delete (xóa folder), modular tốt cho microservice migration. Cons: cross-feature common code phải tách riêng (state, error, middleware vẫn cần folder common), khó "find all handlers", risk duplication khi dev không biết common có gì. Khi nào chọn cái nào: project nhỏ-vừa (< 50 endpoint) + domain logic ở crate riêng → layer-based đủ; project lớn (100+ endpoint) + nhiều team dev song song trên feature khác nhau → feature-folder để giảm merge conflict; project medium-large + cross-cutting concern cần share → hybrid như Shop API.
  2. Shop API hybrid: (a) Layer-based cho infrastructurestate.rs (AppState struct), router.rs (build_router aggregate), app.rs (bootstrap), responses/ (shared response, custom IntoResponse), middleware/ (request-id, auth, rate-limit), extractors/ (CurrentUser, ValidatedJson), dto/ (DTO per resource cũng đặt ở layer này); (b) Feature-aware cho route/handler per resourceroutes/<resource>.rs (vd routes/product.rs, routes/order.rs), handlers/<resource>.rs (vd handlers/product.rs), dto/<resource>.rs (vd dto/product.rs) — mỗi resource có file riêng ở mỗi folder. Tại sao hybrid: (a) Shop API size vừa 60 endpoint phân vào 10 resource — feature-folder thuần với 10 folder × 4 file = 40 file là dư over-engineering; layer-based với routes/ + handlers/ chứa 10 file mỗi 30-80 dòng đọc dễ; (b) Cross-resource code (state, response envelope, middleware, custom extractor) cần share — feature-folder thuần phải tách layer common riêng, cuối cùng vẫn có 2 paradigm; (c) Domain logic (business logic, repository) đẩy về crate shop-core riêng (init G4) qua Clean Architecture — shop-api chỉ là HTTP layer (route + handler + DTO), tự nhiên gọn không cần feature-folder full.
  3. Router::merge(sub_router) gộp sub-router cùng cấp path không thêm prefix. Sub-router định nghĩa route nào → route đó xuất hiện ở root level master router. Router::nest(prefix, sub_router) thêm prefix vào mọi route trong sub-router. Vd cụ thể: routes::product::routes() trả Router chứa route /products (giả sử). (a) Router::new().merge(routes::product::routes()) → endpoint cuối GET /products (cùng cấp root, không có prefix). (b) Router::new().nest("/api/v1", routes::product::routes()) → endpoint cuối GET /api/v1/products (nest thêm prefix /api/v1 vào mọi route trong sub). Khi nào dùng cái nào: merge dùng cho route không cần prefix chung (vd /health, /version ở root level) hoặc đã build sẵn prefix vào sub; nest dùng để đẩy nhiều resource vào một prefix (vd nest /api/v1 chứa products, orders, cart — pattern Shop API sẽ áp dụng B24). Trade-off: merge đơn giản hơn, nest có overhead nhỏ (axum internal radix tree node thêm tầng) nhưng không đáng kể, gain tổ chức prefix lớn hơn nhiều.
  4. AppState wrap field qua Arc internal vì 3 lý do: (a) Clone-cheap — axum yêu cầu AppState: Clone để extractor State<AppState> hoạt động (mỗi request handler nhận một clone). Wrap field qua Arc làm clone chỉ tăng reference count (atomic increment ~nanoseconds), không deep-copy AppConfig heap data. Nếu không wrap qua Arc, mỗi request clone deep AppConfig (allocate string, copy bytes) — lãng phí cho data immutable; (b) Share read-only safe cross-threadArc<T> implement Send + Sync khi T: Send + Sync, cho phép share AppConfig giữa nhiều task tokio chạy concurrent trên nhiều worker thread. Không cần lock (Mutex/RwLock) vì data immutable; (c) Idiomatic Rust async — pattern Arc<T> cho immutable shared data là chuẩn Rust async ecosystem (sqlx PgPool internally dùng Arc, redis-rs RedisPool cũng vậy). Pattern share cross-handler: qua extractor State<AppState> (deep dive B28). Handler signature: async fn list_products(State(state): State<AppState>) -> AppResult<Json<...>> — axum tự inject clone của state vào arg khi handler được gọi. Tại sao impl Clone bắt buộc: trait bound của State<S> extractor yêu cầu S: Clone + Send + Sync + 'static. Compiler check lúc build router, nếu AppState không impl Clone sẽ báo error "the trait Clone is not implemented for AppState". #[derive(Clone)] đủ vì field Arc<AppConfig> đã impl Clone (tăng ref count) — Rust derive auto-generate Clone cho struct khi mọi field cũng Clone.
  5. main.rs sau B17 chỉ 12 dòng — đã chuyển bootstrap logic sang crates/shop-api/src/app.rs, cụ thể function pub async fn run() -> anyhow::Result<()>. Function này đóng gói toàn bộ flow: init telemetry qua telemetry::init_tracing(false), load AppConfig::from_env(), build AppState::new(config), build router qua router::build_router(state), bind TcpListener::bind, axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await, log lifecycle. Helper shutdown_signal() async bắt SIGINT + SIGTERM cũng đặt trong app.rs. main.rs chỉ giữ: mod ... declare 9 module (3 infrastructure file + 1 routes folder + 5 placeholder folder), #[tokio::main] async fn main() -> anyhow::Result<()> { app::run().await } — entry point thực tế là app::run(). Tại sao tách bootstrap khỏi main(): (a) Test integration future — vì run() là async function pub, test harness B253 có thể gọi app::run() trực tiếp từ test code (spawn task + shutdown signal sau khi test xong) thay vì spawn binary process. Hoặc refactor thành build_app() -> Router để gọi router trực tiếp qua axum-test không phải bind real socket; (b) Scale bootstrap dễ — G6 thêm init PgPool, G18 init RedisPool, G15 init metric exporter (Prometheus + tracing exporter OpenTelemetry) — mọi step thêm vào app::run() không phình main.rs; (c) Consistency cross-binaryshop-worker (G21) + shop-cli (G29) cũng follow pattern thin main.rs + app::run() bootstrap — mọi binary workspace cùng structure; (d) Error propagation cleanrun() return anyhow::Result<()>, error chain qua .context("...") trace tốt khi log; main() chỉ ? propagate exit code rõ.
13

Bài Tiếp Theo

— đi sâu signature async fn handler axum: trait bound Send + 'static compiler enforce vì sao, tokio::main macro mở rộng thế nào, tokio::spawn trong handler khi nào cần khi nào không, tokio::time::sleep vs std::thread::sleep blocking pitfall, integrate sâu giữa axum và tokio runtime — chuẩn bị nền cho mọi handler async G3+ scale tốt.