Danh sách bài viết

Bài 24: Nested Routes — Router::nest

Bài 24 của series Rust RESTful API — chi tiết Router::nest(prefix, sub_router) trong axum 0.8 mount sub-router dưới prefix path với path bên trong sub-router là relative (không có prefix), axum tự concat prefix + sub_path để dispatch; phân biệt Router::merge(other) (gộp cùng level, KHÔNG thêm prefix — sub-route giữ nguyên path) vs Router::nest(prefix, other) (mount dưới prefix — mọi sub-route được prepend prefix); multi-level nest cho admin/public split ví dụ nest("/admin", admin_router) trong nest("/api/v1", v1_router) sinh URL /api/v1/admin/products; tradeoff nested vs flat path (nested: prefix tập trung 1 chỗ, đổi v1→v2 1 dòng nhưng trace URL phải lookup nest chain — flat: rõ ràng từng route đầy đủ nhưng lặp prefix mọi nơi); pitfall fallback handler — Router::fallback(handler) trong nested router KHÔNG inherit từ parent, sub-router có fallback riêng; Shop API decision: chỉ 1 fallback top-level với AppError envelope chuẩn (B25 deep dive); refactor lớn workspace state — crates/shop-api/src/routes/products.rs chuyển path từ full /api/v1/products sang relative /products, crates/shop-api/src/router.rs wrap toàn bộ domain route trong Router::nest("/api/v1", api_v1) aggregate gọn; URL client KHÔNG đổi sau refactor — chỉ thay đổi internal code structure; pattern lock vĩnh viễn cho Shop API: nest cho organizational prefix (/api/v1, /admin, /webhooks tương lai), merge cho cùng level service không liên quan (/health, /version, /metrics ở root).

13/06/2026
10 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 Router::nest(prefix, sub_router) mount sub-router dưới prefix, path bên trong sub-router là relative — axum auto concat prefix + sub_path khi dispatch.
  • Phân biệt Router::merge(other) (cùng level, KHÔNG thêm prefix — sub-route giữ nguyên path) vs Router::nest(prefix, other) (mount dưới prefix — sub-route được prepend prefix).
  • Áp dụng multi-level nest cho admin/public split (ví dụ /api/v1/admin/products qua hai lớp nest).
  • Hiểu tradeoff nested vs flat path: nested tập trung prefix 1 chỗ dễ đổi version, flat rõ ràng từng route nhưng lặp prefix.
  • Refactor Shop API: routes/products.rs dùng path relative /products/..., router.rs nest /api/v1 aggregate gọn.
  • Biết pitfall fallback handler trong nested router — KHÔNG inherit từ parent; Shop API quyết định 1 fallback top-level (B25 deep dive).
2

Router::nest(prefix, sub_router) Cơ Bản

Router::nest(prefix, sub_router) là phương thức mount (gắn) một sub-router xuống dưới một path prefix cho trước. Mọi route trong sub-router sẽ được dispatch khi URL đầy đủ là prefix + sub_path tương ứng.

Quy tắc cốt lõi cần nhớ: path khai báo trong sub-router là relative, KHÔNG bao gồm prefix. axum tự ghép prefix với path con khi build routing table — handler chỉ cần biết đường đi nội bộ của domain mình, không cần biết version path hay namespace bên ngoài.

use axum::{Router, routing::{get, post}};

async fn list_products() -> &'static str { "list products" }
async fn create_product() -> &'static str { "create product" }
async fn get_product() -> &'static str { "get product" }

// Sub-router — path bên trong là RELATIVE, không có prefix /api/v1
let products_router = Router::new()
    .route("/", get(list_products).post(create_product))
    .route("/:slug", get(get_product));

// Mount dưới prefix /api/v1/products
let app = Router::new().nest("/api/v1/products", products_router);

// Effective routes (URL client gọi):
//    GET   /api/v1/products
//    POST  /api/v1/products
//    GET   /api/v1/products/:slug

Quan sát ba điểm:

  • Trong products_router, path "/" không có ý nghĩa "root của app" — nó nghĩa là "root của sub-router" (sau khi mount sẽ thành chính prefix /api/v1/products).
  • Path "/:slug" mount xuống thành /api/v1/products/:slug — axum nối prefix với sub-path tự động, dynamic segment :slug giữ nguyên semantic Path extractor (B22).
  • Handler list_products trong sub-router KHÔNG cần biết prefix bên ngoài là /api/v1/products — nó chỉ biết bản thân được mount ở "/". Đổi prefix sang /api/v2/products hay /internal/products không cần sửa handler hay sub-router.

Đây là tính chất modular quan trọng nhất của nest: tách bạch route declaration khỏi mount point. Sub-router là một unit độc lập, reusable, testable riêng — có thể mount nhiều lần ở các prefix khác nhau nếu cần.

3

merge vs nest — Khác Biệt

Trước B24, Shop API dùng Router::merge(other)router.rs aggregate sub-router (lock B17, B18, B21). Đến B24 thêm Router::nest(prefix, other). Hai method khác biệt rõ ràng cần phân biệt để dùng đúng tình huống.

Router::merge(other) gộp hai router ở cùng level path, KHÔNG thêm prefix. Mọi route trong other giữ nguyên path khai báo gốc, được dispatch như thể đã khai báo trực tiếp trong app chính.

Router::nest(prefix, other) mount router con dưới prefix, mọi route trong other được prepend prefix lúc dispatch.

Operation                  | sub_router có route  | App effective route
---------------------------+----------------------+----------------------
.merge(sub)                | "/api/v1/products"   | "/api/v1/products"
.merge(sub)                | "/"                  | "/"
.nest("/api/v1", sub)      | "/products"          | "/api/v1/products"
.nest("/api/v1", sub)      | "/"                  | "/api/v1"
.nest("/api/v1/products", sub) | "/"              | "/api/v1/products"
.nest("/api/v1/products", sub) | "/:slug"         | "/api/v1/products/:slug"

Ví dụ minh họa cùng sub-router nhưng hai cách compose khác nhau:

use axum::{Router, routing::get};

async fn list_products() -> &'static str { "list products" }

// Approach A: merge — sub-router khai báo full path
let sub_a = Router::new().route("/api/v1/products", get(list_products));
let app_a = Router::new().merge(sub_a);
// Effective: GET /api/v1/products

// Approach B: nest — sub-router khai báo path relative
let sub_b = Router::new().route("/products", get(list_products));
let app_b = Router::new().nest("/api/v1", sub_b);
// Effective: GET /api/v1/products (cùng kết quả với A)

Hai approach trên cho cùng URL effective /api/v1/products, nhưng nội dung sub-router khác nhau: Approach A lặp prefix trong từng route, Approach B chỉ khai báo path domain — prefix tập trung ở nest().

Khi nào dùng cái nào:

  • merge: gộp các service top-level KHÔNG liên quan nhau, không chia sẻ prefix chung. Ví dụ Shop API root có 3 service riêng — /health (infra), /version (build metadata), /metrics (Prometheus G15). Mỗi service tự khai báo path đầy đủ.
  • nest: tách module theo prefix organizational. Ví dụ /api/v1/* cho REST API public, /admin/* cho admin dashboard, /webhooks/* cho integration partner. Mỗi prefix gom một bundle route logic liên quan.
4

Multi-Level Nest

Sub-router được mount qua nest bản thân vẫn là một Router bình thường — có thể chứa nest bên trong nó. Pattern này gọi là multi-level nest, dùng cho cấu trúc URL phân tầng admin/public.

use axum::{Router, routing::get};

async fn admin_list_products() -> &'static str { "admin list" }
async fn public_list_products() -> &'static str { "public list" }

// Sub-router admin — path relative trong namespace admin
let admin_router = Router::new()
    .route("/products", get(admin_list_products));

// Sub-router public products
let products_router = Router::new()
    .route("/", get(public_list_products));

// Sub-router v1 — gom admin + public lại trong namespace /api/v1
let v1_router = Router::new()
    .nest("/admin", admin_router)
    .nest("/products", products_router);

// App root — mount v1 dưới /api/v1
let app = Router::new().nest("/api/v1", v1_router);

// Effective routes:
//    GET /api/v1/admin/products    → admin_list_products
//    GET /api/v1/products          → public_list_products

Hai lớp nest tách bạch concerns: /api/v1 là versioning boundary (đổi sang v2 chỉ sửa 1 dòng), /admin là role boundary (sau này thêm middleware require_role("admin") qua admin_router.layer(...) chỉ áp cho admin route, không ảnh hưởng public — B29 deep dive route_layer).

Cấu trúc cây route sau multi-level nest:

app (root)
└── /api/v1            ← nest level 1 (versioning)
    ├── /admin         ← nest level 2 (role)
    │   └── /products  ← route in admin_router
    └── /products      ← nest level 2 (resource)
        └── /          ← route in products_router
            (effective: /api/v1/products)

Khuyến nghị: tối đa 2-3 level cho readability. Quá nhiều level nest khiến trace URL từ raw code phải lookup chain dài, mất tính rõ ràng. Shop API hiện chỉ cần 1 level (/api/v1) ở B24, sẽ thêm level 2 (/admin) khi đến G14 RBAC (Bài 131+).

5

Tradeoff Nested vs Flat Path

Hai approach compose routing là flat path (mỗi route khai báo path đầy đủ, dùng merge ghép) và nested (path relative trong sub-router, dùng nest mount với prefix).

Flat path:

  • Pros: rõ ràng tuyệt đối — nhìn 1 file biết URL đầy đủ, không cần trace nest chain. IDE jump-to-definition trỏ thẳng handler.
  • Cons: lặp prefix /api/v1/ ở mỗi .route() call. Đổi version từ v1 sang v2 phải search-replace hàng chục file resource, dễ sót.

Nested:

  • Pros: prefix tập trung 1 chỗ (router.rs) — đổi v1v2 sửa 1 dòng. Sub-router reusable, mount nhiều prefix nếu cần (vd mount cùng router dưới /api/v1/api/v2 để hỗ trợ song song).
  • Cons: trace URL từ raw code phải lookup chain — đọc routes/products.rs thấy /products nhưng URL effective phụ thuộc router.rs nest ở đâu. IDE-friendly (search reference) nhưng grep raw text phức tạp hơn.

Shop API decision lock B24: dùng hybridnest cho organizational prefix (/api/v1, /admin, /webhooks tương lai), flat trong sub-router cho resource path (/products, /products/:slug, /products/popular). Lý do:

  • Versioning (v1, v2) là dimension tách bạch độc lập với domain — đặt ở 1 chỗ duy nhất giúp version bump không lan tỏa.
  • Resource path (/products, /products/:slug) trong cùng sub-router KHÔNG nest sâu thêm vì khiến đọc handler khó: nest("/products", Router::new().route("/", ...).route("/:slug", ...)) 2 lớp khá lắt léo cho 2-3 route resource thông thường, flat route("/products", ...).route("/products/:slug", ...) trực quan hơn.

Quy tắc thực hành: nest cho boundary cross-cutting (version, role, integration); flat cho resource path trong cùng domain.

6

Fallback Handler Trong Nested Router

Router::fallback(handler) đăng ký handler dự phòng chạy khi không route nào match URL request. Mặc định, axum trả 404 với body plain text "Not found" — fallback cho phép custom envelope chuẩn project.

Pitfall quan trọng: fallback trong sub-router (mount qua nest) hoạt động tách biệt với fallback của parent router — KHÔNG inherit theo cây. Nếu sub-router có fallback riêng, request không match route nào trong sub-router (nhưng vẫn nằm dưới prefix) sẽ rơi vào fallback của sub-router, KHÔNG bubble lên parent.

use axum::{Router, routing::get, http::StatusCode};

async fn list_products() -> &'static str { "list" }

let products_router = Router::new()
    .route("/", get(list_products))
    .fallback(|| async {
        (StatusCode::NOT_FOUND, "no product matches")
    });

let app = Router::new()
    .nest("/api/v1/products", products_router)
    .fallback(|| async {
        (StatusCode::NOT_FOUND, "app fallback")
    });

// Request /api/v1/products/unknown
//   → match prefix /api/v1/products, không match route nào trong sub-router
//   → rơi vào sub-router fallback: "no product matches"
//
// Request /random
//   → không match prefix nào
//   → rơi vào app-level fallback: "app fallback"

Hệ quả của tách biệt này: nếu muốn 404 nhất quán toàn app (cùng envelope, cùng header, cùng log format), cách đơn giản nhất là chỉ đặt fallback ở top-level và KHÔNG đặt fallback trong sub-router. Mọi request không match đều rơi xuống fallback top-level, response thống nhất.

Shop API decision lock B24: chỉ 1 fallback top-level trong router.rs, KHÔNG add fallback trong sub-router (routes/products.rs, routes/health.rs, ...). Fallback trả 404 + AppError::NotFound envelope chuẩn lock B3 với code = "NOT_FOUND". Implementation chi tiết ở B25 (Route Merge & Fallback Handler) cùng với method_not_allowed_fallback trả 405 + Allow header tự sinh.

Lý do quyết định: (a) consistency — client nhận envelope JSON chuẩn dù URL sai ở namespace nào; (b) observability — 1 chỗ log "unmatched URL"/access log, đếm 404 metric dễ; (c) đơn giản maintain — không có sub-router quên fallback rồi fall-through default axum text "Not found" bị lẫn lộn.

7

Refactor Shop API: Nest /api/v1

Đến phần áp dụng vào Shop API. Trước B24, routes/products.rs đang dùng full path /api/v1/products ở từng .route() call (lock từ B21 — URL pattern tạm thời). B24 refactor: tách prefix /api/v1 ra router.rs qua nest, sub-router routes/products.rs giữ path relative /products/....

Cập nhật crates/shop-api/src/routes/products.rs — đổi mọi "/api/v1/products" sang "/products", mọi thứ khác giữ nguyên:

// File: crates/shop-api/src/routes/products.rs
// Routes are now RELATIVE — prefix "/api/v1" được prepend bởi router.rs nest
use axum::{
    extract::{Path, Query},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use serde_json::json;
use shop_common::pagination::{ListResponse, Pagination};

use crate::state::AppState;

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/products", get(list_products).post(create_product))
        .route("/products/popular", get(list_popular))
        .route(
            "/products/:slug/related/:related_slug",
            get(get_related_product),
        )
        .route(
            "/products/:slug",
            get(get_product)
                .put(replace_product)
                .patch(update_product)
                .delete(delete_product),
        )
}

// 8 handler skeleton từ B21+B22+B23 GIỮ NGUYÊN nội dung — chỉ path string đổi
async fn list_products(
    Query(pagination): Query<Pagination>,
) -> Json<serde_json::Value> {
    tracing::info!(
        page = pagination.page,
        size = pagination.size,
        "listing products",
    );
    let response = ListResponse::<serde_json::Value>::new(vec![], 0, &pagination);
    Json(serde_json::to_value(response).unwrap())
}

// create_product, list_popular, get_product, replace_product, update_product,
// delete_product, get_related_product — body GIỮ NGUYÊN từ B21+B22.

Cập nhật crates/shop-api/src/router.rs — wrap sub-router domain trong nest("/api/v1", ...), infrastructure route (/, /health, /version, /error/*, /demo/*) giữ merge ở root level:

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

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

pub fn build_router(state: AppState) -> Router {
    // Sub-router gom toàn bộ domain route public dưới /api/v1
    let api_v1 = Router::new().merge(routes::products::routes());
    // Tương lai aggregate thêm:
    //   .merge(routes::orders::routes())
    //   .merge(routes::cart::routes())
    //   .merge(routes::categories::routes())
    //   .merge(routes::reviews::routes())

    Router::new()
        .route("/", get(root))
        .merge(routes::health::routes())     // infrastructure — root level
        .merge(routes::version::routes())    // infrastructure — root level
        .merge(routes::demo_error::routes()) // tạm thời, sẽ remove G3+
        .merge(routes::demo_async::routes()) // tạm thời, sẽ remove G3+
        .nest("/api/v1", api_v1)             // ← NEST hết domain route
        .with_state(state)
}

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

Quan sát hai thay đổi quan trọng:

  • let api_v1 = Router::new().merge(routes::products::routes()); — sub-router api_v1 aggregate mọi resource domain. Hiện chỉ có products (B21 lock), tương lai thêm orders, cart, ... cùng cách: append .merge(routes::<name>::routes()) vào builder chain — KHÔNG cần sửa nest("/api/v1", ...).
  • .nest("/api/v1", api_v1) — gắn toàn bộ api_v1 dưới prefix /api/v1. Đổi version sang /api/v2 chỉ sửa string này — sub-router và mọi handler không cần đổi 1 dòng.

Infrastructure route (/, /health, /version) giữ merge ở root level theo Shop API URL convention (lock từ shop-state.md): /health, /healthz, /readyz, /metrics KHÔNG có version prefix vì là probe infrastructure độc lập domain — Kubernetes liveness/readiness probe + Prometheus scrape gọi đúng path cố định.

8

Verify Endpoints Sau Refactor

Chạy cargo run -p shop-api rồi curl 4 case verify URL effective KHÔNG đổi từ góc nhìn client. Tính transparency này là chỉ dấu refactor đúng — chỉ thay đổi internal code structure, không break contract với consumer.

# Case 1: domain route qua nest /api/v1
curl http://localhost:3000/api/v1/products
# {"items":[],"total":0,"page":1,"size":20,"hasNext":false}

# Case 2: domain route với path parameter qua nest
curl http://localhost:3000/api/v1/products/phone-x
# {"slug":"phone-x","name":"demo product"}

# Case 3: infrastructure route ở root level (không qua nest)
curl http://localhost:3000/health
# {"status":"ok"}

# Case 4: infrastructure root
curl http://localhost:3000/
# shop-api v0.1.0

Cả 4 endpoint trả response giống y B23 — client KHÔNG biết bên trong router structure thay đổi. URL contract giữ nguyên là điều kiện cần của refactor an toàn cho deploy production: deploy refactor không cần coordinate với consumer (mobile app, frontend, partner integration) cập nhật URL.

Sơ đồ routing table sau B24:

app (root, build_router)
├── GET  /                                       → root (router.rs)
├── /health (merge routes::health)               → routes/health.rs
│   └── GET  /health
├── /version (merge routes::version)             → routes/version.rs
│   └── GET  /version
├── /error/* (merge routes::demo_error, tạm)     → routes/demo_error.rs
│   ├── GET  /error/not-found
│   ├── GET  /error/unauthenticated
│   └── GET  /error/rate-limited
├── /demo/* (merge routes::demo_async, tạm)      → routes/demo_async.rs
│   └── POST /demo/background
└── /api/v1 (nest, sub: api_v1)                  → router.rs (inline)
    └── api_v1 (merge routes::products)          → routes/products.rs
        ├── GET, POST  /products
        ├── GET        /products/popular
        ├── GET        /products/:slug/related/:related_slug
        └── GET, PUT, PATCH, DELETE  /products/:slug

Workspace state thay đổi B24: UPDATED crates/shop-api/src/routes/products.rs (path từ full /api/v1/products sang relative /products, 4 entry .route() thay đổi string, 8 handler nội dung giữ nguyên), UPDATED crates/shop-api/src/router.rs (introduce sub-router api_v1, wrap .nest("/api/v1", api_v1)). KHÔNG sửa file khác — Cargo.toml không cần update (không add dependency mới), routes/mod.rs không đổi (sub-router products đã có từ B21).

9

Tổng Kết

  • Router::nest(prefix, sub_router) mount sub-router dưới prefix, path bên trong sub-router là relative; axum auto concat prefix + sub_path khi dispatch.
  • Router::merge(other) gộp router cùng level, KHÔNG thêm prefix — sub-route giữ nguyên path khai báo gốc.
  • Multi-level nest cho admin/public split: nest("/admin", admin_router) trong nest("/api/v1", v1_router) sinh URL /api/v1/admin/products; tối đa 2-3 level readability.
  • Flat vs nested: nested tập trung prefix dễ đổi version, flat rõ ràng tuyệt đối khi đọc raw code; Shop API dùng hybrid — nest cho organizational prefix (/api/v1, /admin), flat trong sub-router cho resource path (/products, /products/:slug).
  • Fallback handler KHÔNG inherit qua nest — sub-router có fallback riêng, request không match route trong sub-router rơi vào fallback của chính sub-router; Shop API quyết định chỉ 1 fallback top-level cho consistency (B25 deep dive).
  • Refactor Shop API B24: routes/products.rs đổi path từ full /api/v1/products sang relative /products; router.rs introduce sub-router api_v1 aggregate domain route, wrap .nest("/api/v1", api_v1).
  • Đổi version v1v2 chỉ sửa 1 dòng string trong router.rs; sub-router và mọi handler không cần đổi.
  • URL client KHÔNG đổi sau refactor — chỉ thay đổi internal code structure; deploy refactor không cần coordinate với consumer cập nhật URL.
10

Bài Tập Củng Cố

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

  1. Phân biệt Router::merge(other)Router::nest(prefix, other). Khi nào dùng cái nào trong Shop API?
  2. Sub-router có route("/x", get(h)), mount qua nest("/api/v1", sub). URL effective client gọi là gì? Trong sub-router, handler h có biết prefix /api/v1 hay không?
  3. Multi-level nest: nest("/admin", admin) trong nest("/api/v1", v1). Sub-router adminroute("/users", get(list_users)). URL effective là gì? Vẽ cây route 3 level.
  4. Fallback handler trong nested router có inherit từ parent không? Hệ quả với Shop API là gì? Vì sao Shop API chọn chỉ 1 fallback top-level?
  5. Shop API muốn add v2 song song v1 (giữ v1 để client cũ dùng, v2 cho client mới). Refactor router.rs như thế nào? Sub-router routes/products.rs có cần đổi không?
Đáp án
  1. Router::merge(other) gộp router ở cùng level path, KHÔNG thêm prefix — mọi route trong other giữ nguyên path khai báo gốc, được dispatch như thể đã khai báo trực tiếp trong app chính. Router::nest(prefix, other) mount router con dưới prefix, mọi route trong other được prepend prefix lúc dispatch — path trong other là relative. Quyết định Shop API: merge cho service top-level KHÔNG liên quan nhau, không chia sẻ prefix chung — infrastructure như /health, /version, /metrics mỗi cái tự khai báo path đầy đủ ở root level. nest cho tách module theo prefix organizational — domain REST API public dưới /api/v1, admin dashboard tương lai dưới /admin, integration partner dưới /webhooks. Pattern lock B24: hybrid merge+nest, nest cho boundary cross-cutting (version, role, integration), flat trong sub-router cho resource path.
  2. URL effective: /api/v1/x — axum nối prefix /api/v1 với sub-path /x tự động lúc dispatch routing table. Handler h bên trong sub-router KHÔNG biết prefix /api/v1 bên ngoài — nó chỉ biết bản thân được mount ở "/x". Đây là tính chất modular quan trọng của nest: tách bạch route declaration khỏi mount point. Hệ quả: đổi prefix mount sang /api/v2/x, /internal/x, hay /legacy/api/v1/x không cần sửa handler hay sub-router — chỉ đổi string ở nest() call trong router.rs. Sub-router reusable, có thể mount nhiều lần ở các prefix khác nhau (vd mount cùng router dưới /api/v1/api/v2 để hỗ trợ song song version).
  3. URL effective: /api/v1/admin/users — axum nối prefix /api/v1 (level 1) + prefix /admin (level 2) + sub-path /users (route trong admin) lúc dispatch. Cây route:
    app (root)
    └── /api/v1            ← nest level 1 (v1_router)
        ├── /admin         ← nest level 2 (admin_router)
        │   └── /users     ← route("/users", get(list_users))
        │       (effective: /api/v1/admin/users)
        └── ... (route khác trong v1_router)
    Mỗi level nest tách concern riêng: /api/v1 là versioning boundary (đổi v1→v2 chỉ sửa 1 dòng), /admin là role boundary (sau này thêm middleware require_role("admin") qua admin_router.layer(...) B29 chỉ áp cho admin route, không ảnh hưởng public). Khuyến nghị tối đa 2-3 level cho readability — quá sâu khiến trace URL từ raw code phải lookup chain dài, mất tính rõ ràng.
  4. KHÔNG inherit. Router::fallback(handler) trong sub-router hoạt động tách biệt với fallback của parent router. Nếu sub-router có fallback riêng, request không match route nào trong sub-router (nhưng vẫn nằm dưới prefix mount) sẽ rơi vào fallback của sub-router, KHÔNG bubble lên parent. Hệ quả với Shop API: nếu mỗi sub-router (products, orders, cart) tự đặt fallback, mỗi cái có thể trả 404 envelope khác nhau (khác message, khác header, khác log format), client trải nghiệm không nhất quán + observability rải rác (nhiều chỗ log "unmatched URL", khó đếm metric 404 tổng). Shop API decision lock B24: chỉ 1 fallback top-level trong router.rs, KHÔNG add fallback trong sub-router. Mọi request không match đều rơi xuống fallback top-level, response thống nhất AppError::NotFound envelope chuẩn lock B3 với code = "NOT_FOUND". Implementation chi tiết ở B25 cùng với method_not_allowed_fallback trả 405 + Allow header. Lý do: consistency cho client + 1 chỗ log/metric tập trung + đơn giản maintain không lo sub-router quên fallback rồi fall-through default axum text "Not found".
  5. Refactor router.rs thêm sub-router api_v2 song song api_v1, mỗi cái nest dưới prefix tương ứng. Trường hợp v2 dùng route giống hệt v1 (chỉ khác version path, semantic API giống nhau):
    pub fn build_router(state: AppState) -> Router {
        let api_v1 = Router::new().merge(routes::products::routes());
        let api_v2 = Router::new().merge(routes::products::routes()); // reuse sub-router
    
        Router::new()
            .route("/", get(root))
            .merge(routes::health::routes())
            .merge(routes::version::routes())
            .nest("/api/v1", api_v1)
            .nest("/api/v2", api_v2)   // ← song song
            .with_state(state)
    }
    Reuse cùng routes::products::routes() ở cả hai version — vì routes::products::routes() trả Router<AppState> mới mỗi lần gọi (factory pattern). Sub-router routes/products.rs KHÔNG cần đổi 1 dòng — đây chính là pros lớn nhất của nested approach so với flat (đã thấy ở bước 5). Trường hợp v2 có breaking change cần khác handler (vd field renamed, response structure đổi): tạo file routes/products_v2.rs riêng với fn routes() -> Router<AppState> chứa handler v2, sửa let api_v2 = Router::new().merge(routes::products_v2::routes());. Sub-router routes/products.rs (v1) vẫn KHÔNG cần đổi. Strategy versioning chi tiết (URL vs header vs query) sẽ deep dive ở B30 (API Versioning).
11

Bài Tiếp Theo

— đi sâu vào Router::merge use case cụ thể, fallback handler patterns (404 custom với AppError envelope, method_not_allowed_fallback trả 405 + Allow header tự sinh), test fallback trong Shop API.