Danh sách bài viết

Bài 28: Route Với State<T>

Bài 28 của series Rust RESTful API — đi sâu vào AppState pattern cho shared resource cross-handler trong axum: struct AppState với #[derive(Clone)] và Arc internal cho field lớn (PgPool và RedisPool đã có Arc bên trong nên clone trực tiếp, AppConfig struct read-only wrap Arc<AppConfig> để clone không copy bytes), Router::with_state(state) provide state cho mọi handler trong router tree resolve Router<AppState> → Router<()> sẵn sàng cho axum::serve, sub-router define type signature Router<AppState> generic over state để hoãn việc lock state đến phút cuối ở router.rs, State<AppState> extractor trong arg list handler đặt đầu theo convention B13 (trước Path/Query/Json), refactor list_products dùng State<AppState> + Query<Pagination> log thêm state.config.port cho structured tracing, lịch extend AppState dần (G6 thêm pool: PgPool, G18 thêm redis: RedisPool, G15 metric handle, G14 Casbin enforcer cho authorization), pattern test với mock state qua trait abstract ProductRepo + field Arc<dyn ProductRepo> + Mock impl cho unit test fast không touch DB/Redis thật (chi tiết B256), decision lock từ B28 cho AppState strategy áp dụng xuyên suốt series.

14/06/2026
10 phút đọc
1 lượt xem
1

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

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

  • Hiểu vai trò AppState trong axum app — shared resource container cross-handler (config, pool, redis, metrics).
  • Biết Router::with_state(state) provide state cho mọi handler trong router tree.
  • Sử dụng State<AppState> extractor trong handler arg list (đặt đầu theo convention B13).
  • Hiểu pattern Clone + Arc internal — clone-cheap để axum share state cross-thread hiệu quả.
  • Biết lịch extend AppState dần qua các group: G6 thêm pool: PgPool, G18 thêm redis: RedisPool, G15 metric handle.
  • Hiểu pattern test với mock state — trait abstract + Arc<dyn Trait> field + Mock impl cho unit test fast.
  • Refactor handler list_products Shop API dùng State<AppState> extractor (preview G7 implement đầy đủ với DB).
2

Vấn Đề: Handler Cần Resource Chung

Mọi handler trong Shop API thực tế cần truy cập shared resource: kết nối DB (PostgreSQL pool), kết nối Redis (cache + session), config (port, JWT secret, CORS origin), metric registry (Prometheus), tracer handle, Casbin enforcer cho authorization. Câu hỏi cốt lõi: làm thế nào để mỗi handler truy cập cùng resource mà không phải pass thủ công qua mọi function call?

Hai anti-pattern thường gặp:

  • Global static variable — khai báo static POOL: OnceLock<PgPool> = OnceLock::new(); rồi handler nào cần thì gọi POOL.get().unwrap(). Nhược: (a) hard test — không inject mock được, mỗi test phải init real pool hoặc dùng OnceLock::set hack-ish; (b) race condition lúc init nếu nhiều thread cùng gọi get_or_init không đúng pattern; (c) khó refactor khi cần multi-tenant với multiple pool.
  • Từng handler tạo connection mới — handler nào cần DB thì PgConnection::connect(&url).await tại đó. Nhược: (a) resource leak — connection không pool, mỗi request tạo socket TCP mới + TLS handshake + auth handshake ~50-200ms overhead; (b) PostgreSQL có giới hạn max_connections default 100, traffic spike vượt giới hạn → server reject connection mới → 500 hàng loạt; (c) lãng phí — connection idle vài giây giữa các request bị đóng thay vì reuse.

Pattern chuẩn axum: AppState chứa mọi resource, share qua State<AppState> extractor. State khởi tạo 1 lần ở bootstrap (app.rs lock B17), pass vào Router::with_state(state), axum tự clone state vào mỗi request → handler nhận qua extractor type-safe.

┌───────────────────────────────────────────────────────────┐
│  Bootstrap (app.rs)                                       │
│  ─────────────────                                        │
│  AppConfig::from_env() ─┐                                 │
│                         ├─→ AppState::new(config)         │
│                         │   { config: Arc<AppConfig> }    │
│                         │                                 │
│  build_router(state) ───┘                                 │
│        │                                                  │
│        ▼                                                  │
│  Router<AppState> .with_state(state) → Router<()>          │
│                                                           │
│  Request lifecycle (mỗi request)                          │
│  ──────────────────                                       │
│  Incoming request                                         │
│        │                                                  │
│        ▼                                                  │
│  axum clone state (cheap nhờ Arc) ──→ handler arg         │
│        │                                                  │
│        ▼                                                  │
│  handler(State(state): State<AppState>, ...) → Response   │
└───────────────────────────────────────────────────────────┘
3

AppState Pattern — Clone + Arc Internal

Recap state.rs đã define ở B17 (lock Project Structure):

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

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    // Sẽ thêm dần:
    // pub pool: PgPool,        // G6 (B56)
    // pub redis: RedisPool,    // G18 (B184)
    // pub metric: MetricHandle, // G15 (B152)
}

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

Hai yêu cầu cốt lõi:

  • #[derive(Clone)] — bắt buộc. axum yêu cầu state type impl Clone vì internal mỗi request được pass một bản clone của state cho extractor. Bound này enforce trong FromRequestParts cho State<T> với T: Clone + Send + Sync + 'static.
  • Field lớn wrap Arc<T> để clone-cheap — clone Arc<T> chỉ tăng atomic counter (vài nano-second), không copy bytes của T. Ngược lại clone AppConfig trực tiếp sẽ copy hàng chục field String + nested struct → tốn CPU + memory cho mỗi request.

Quy tắc wrap Arc cho field:

Field type          │ Đã có Arc internal? │ Wrap Arc thêm?
────────────────────┼─────────────────────┼───────────────
PgPool (sqlx)       │ Yes                 │ No — clone trực tiếp
RedisPool           │ Yes                 │ No — clone trực tiếp
(deadpool-redis)    │                     │
AppConfig (struct   │ No                  │ Yes → Arc<AppConfig>
read-only)          │                     │
Arc<dyn Trait>      │ Yes (Arc itself)    │ No — clone trực tiếp
(repo trait obj)    │                     │
reqwest::Client     │ Yes (internal Arc)  │ No — clone trực tiếp

sqlx::PgPool bên trong là Arc<PoolInner> nên clone chỉ tăng counter — wrap thêm Arc<PgPool> là double-Arc dư thừa. Đọc source sqlx-core/src/pool/mod.rs để confirm: pub struct Pool<DB: Database>(pub(crate) Arc<PoolInner<DB>>). Tương tự reqwest::Client, deadpool-redis::Pool, tonic::transport::Channel — kiểm tra docs.rs trước khi wrap thêm.

AppConfig ngược lại là plain struct với field String + u16 + nested config struct, KHÔNG có Arc internal → wrap Arc<AppConfig> để clone cheap. Vì config read-only sau load env, immutable không cần MutexArc đủ.

Cảnh báo pitfall: KHÔNG wrap Mutex<T> bên trong AppState cho hot path — mọi request access cùng mutex sẽ serialize. Pattern đúng cho mutable shared state: Arc<tokio::sync::RwLock<T>> (read-heavy) hoặc dùng channel + actor pattern. Shop API không có mutable hot state (mọi mutation đi qua DB).

4

Router::with_state(state) — Provide State

Router trong axum là type generic 2 parameter: Router<S = (), B = Body>. Tham số S chính là state type — mặc định () nghĩa là router không cần state. Khi handler dùng State<AppState>, compiler yêu cầu router phải là Router<AppState>.

Recap build_router hiện tại (lock B17 + B24 + B25):

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

pub fn build_router(state: AppState) -> Router {
    let api_v1 = Router::new()
        .merge(routes::products::routes());

    Router::new()
        .route("/", get(root))
        .merge(routes::health::routes())
        .merge(routes::version::routes())
        .merge(routes::demo_error::routes())
        .merge(routes::demo_async::routes())
        .nest("/api/v1", api_v1)
        .fallback(handlers::fallback::not_found)
        .method_not_allowed_fallback(handlers::fallback::method_not_allowed)
        .with_state(state)   // ← lock state in tại đây
}

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

Vai trò .with_state(state) ở cuối chain: resolve type signature Router<AppState>Router<()> bằng cách "lock in" state. Sau dòng này, router không còn dependency vào generic S nữa — sẵn sàng cho axum::serve(listener, app) vốn yêu cầu Router<()>.

Type signature trước và sau .with_state:

// Trước với_state
let router: Router<AppState> = Router::new()
    .merge(routes::products::routes())  // Router<AppState>
    .nest("/api/v1", api_v1);            // Router<AppState>

// Sau with_state — state đã lock in, không còn generic S
let router: Router = router.with_state(state); // Router (Router<()>)

// axum::serve yêu cầu Router<()>
axum::serve(listener, router).await?;

Sub-router define Router<AppState> generic over state — pattern lock B17. Mỗi file routes/<name>.rs trả về Router<AppState> để hoãn việc consume state đến phút cuối ở router.rs:

// File: crates/shop-api/src/routes/products.rs
use axum::{Router, routing::get};
use crate::state::AppState;

pub fn routes() -> Router<AppState> {  // ← generic over state
    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 internal định nghĩa bên dưới...

Lý do sub-router KHÔNG tự gọi .with_state(state): state chỉ có ở app.rs bootstrap, sub-router không biết về AppConfig + port. Hoãn lock state đến router.rs giúp sub-router reusable nếu một ngày bạn muốn test sub-router riêng với mock state khác.

5

State<AppState> Extractor Trong Handler

Handler nhận State<AppState> trong arg list để access shared resource. Extractor State<T> impl trait FromRequestParts (không consume body) → có thể đặt bất cứ vị trí nào trong arg list, nhưng convention Shop API là đặt đầu (lock B13).

// File: crates/shop-api/src/routes/products.rs
use axum::{
    extract::{Query, State},
    Json,
};
use shop_common::pagination::{ListResponse, Pagination};
use crate::state::AppState;

async fn list_products(
    State(state): State<AppState>,         // ← extractor đặt đầu
    Query(pagination): Query<Pagination>,
) -> Json<serde_json::Value> {
    tracing::info!(
        port = state.config.port,
        page = pagination.page,
        size = pagination.size,
        "listing products"
    );
    // G7 (B64) sẽ thay bằng: state.pool fetch products từ DB
    let response = ListResponse::<serde_json::Value>::new(vec![], 0, &pagination);
    Json(serde_json::to_value(response).unwrap())
}

Cú pháp destructure State(state): State<AppState> theo style 2 lớp giống Path((slug, related_slug)) ở B22. Sau dòng này biến state có type AppState trực tiếp, truy cập field qua state.config.port hoặc sau G6 sẽ là state.pool.

Convention thứ tự arg (lock B13): extractor không consume body đặt trước extractor consume body (vì body chỉ extract được 1 lần, phải ở cuối). Shop API quy ước cụ thể:

1. State<AppState>      ← đầu tiên (luôn cần access resource)
2. Path<T> / TypedHeader  ← param URL + header
3. Query<T>              ← query string
4. Extension<CurrentUser> ← middleware-injected (B39, G11)
5. Json<T>               ← cuối cùng (consume body)

Multi-handler dùng cùng AppState → clone diễn ra tự động cho mỗi request, cheap nhờ Arc internal. Throughput không bị ảnh hưởng dù bạn có 100 handler cùng nhận State<AppState>.

So sánh với Extension<T> (B39 deep dive):

  • State<T> — static typed, compile-time check, axum biết trước type qua Router<S> generic. Use case: shared resource khởi tạo 1 lần bootstrap (config, pool, redis).
  • Extension<T> — dynamic, runtime check qua HashMap<TypeId, Any>. Use case: request-scoped data inject từ middleware (request ID B39, CurrentUser sau JWT verify B112).
6

Extend AppState Dần Theo Lịch

AppState bắt đầu nhỏ và lớn dần theo các group. Lịch extend lock vĩnh viễn:

// Hiện tại (B28) — sau B17
#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
}

// G6 (B56) — thêm PgPool sau khi shop-db init
use sqlx::PgPool;

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    pub pool: PgPool,  // ← PgPool có Arc internal, không wrap thêm
}

// G18 (B184) — thêm RedisPool sau khi shop-cache init
use deadpool_redis::Pool as RedisPool;

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    pub pool: PgPool,
    pub redis: RedisPool,  // ← deadpool-redis Pool có Arc internal
}

// G15 (B152) — thêm metric handle cho Prometheus
use metrics_exporter_prometheus::PrometheusHandle;

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    pub pool: PgPool,
    pub redis: RedisPool,
    pub metric: PrometheusHandle,  // ← Arc internal
}

// G14 (B135) — thêm Casbin enforcer cho authorization
use casbin::Enforcer;
use tokio::sync::RwLock;

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    pub pool: PgPool,
    pub redis: RedisPool,
    pub metric: PrometheusHandle,
    pub enforcer: Arc<RwLock<Enforcer>>,  // ← reload policy runtime
}

Pattern incremental: extend field, KHÔNG break existing handler. Handler chỉ access field mình cần — thêm pool không bắt buộc list_products phải sửa, chỉ khi handler thực sự query DB mới dùng state.pool.

Lock convention tên field (snake_case, descriptive nhưng không dư từ vì context AppState đã rõ):

  • pool — KHÔNG dùng db_pool vì context đã rõ là database. Nếu một ngày có 2 pool (read replica + primary) mới đổi sang pool_primary + pool_replica.
  • redis — KHÔNG dùng redis_pool vì context đã rõ. Tương tự cho cache_pool.
  • metric — KHÔNG dùng metric_handle.
  • enforcer — Casbin convention, đủ rõ.
  • config — luôn là Arc<AppConfig> read-only.

Constructor AppState::new sẽ extend theo cùng nhịp — nhận thêm arg cho từng field mới:

// G6 constructor
impl AppState {
    pub fn new(config: AppConfig, pool: PgPool) -> Self {
        Self {
            config: Arc::new(config),
            pool,
        }
    }
}

// G18 constructor
impl AppState {
    pub fn new(config: AppConfig, pool: PgPool, redis: RedisPool) -> Self {
        Self {
            config: Arc::new(config),
            pool,
            redis,
        }
    }
}

Bootstrap app.rs (lock B17) sẽ update tương ứng — load config → init pool → init redis → AppState::new(config, pool, redis).

7

Testing Với Mock State

Unit test handler không nên touch DB/Redis thật — chậm (testcontainers spin up ~5s/test), flaky (port conflict, network glitch), khó parallel. Pattern chuẩn: trait abstract repository + Arc<dyn Trait> field trong AppState + Mock impl cho test.

Preview (chi tiết B256):

// File: crates/shop-core/src/repository/product.rs (G4)
use async_trait::async_trait;
use shop_common::pagination::Pagination;

#[async_trait]
pub trait ProductRepo: Send + Sync {
    async fn list(&self, pagination: &Pagination) -> Result<(Vec<Product>, u64), Error>;
    async fn get_by_slug(&self, slug: &str) -> Result<Option<Product>, Error>;
}

// File: crates/shop-api/src/state.rs (extend G6+)
use std::sync::Arc;
use shop_core::repository::ProductRepo;

#[derive(Clone)]
pub struct AppState {
    pub config: Arc<AppConfig>,
    pub pool: PgPool,
    pub product_repo: Arc<dyn ProductRepo>,  // ← trait object, swap được cho test
}

Bootstrap production wire impl thật từ shop-db:

// File: crates/shop-api/src/app.rs (G6 update)
use shop_db::repos::PostgresProductRepo;

let pool = PgPool::connect(&config.database_url).await?;
let product_repo: Arc<dyn ProductRepo> = Arc::new(PostgresProductRepo::new(pool.clone()));
let state = AppState {
    config: Arc::new(config),
    pool,
    product_repo,
};

Test wire mock impl không touch DB:

// File: crates/shop-api/tests/products_test.rs (B256)
use async_trait::async_trait;

#[derive(Default)]
struct MockProductRepo {
    products: Vec<Product>,
}

#[async_trait]
impl ProductRepo for MockProductRepo {
    async fn list(&self, _p: &Pagination) -> Result<(Vec<Product>, u64), Error> {
        Ok((self.products.clone(), self.products.len() as u64))
    }
    async fn get_by_slug(&self, slug: &str) -> Result<Option<Product>, Error> {
        Ok(self.products.iter().find(|p| p.slug == slug).cloned())
    }
}

#[tokio::test]
async fn test_list_products_empty() {
    let state = AppState {
        config: Arc::new(AppConfig::test_fixture()),
        pool: PgPool::connect_lazy("postgres://test").unwrap(),  // never used
        product_repo: Arc::new(MockProductRepo::default()),
    };
    let app = build_router(state);
    // axum-test client call /api/v1/products
    // assert response.json().items == [] && total == 0
}

Lock decision: từ G6 trở đi (B62) khi shop-core::repository trait đầu tiên define, AppState extend Arc<dyn Trait> cho mọi repository dependency để mock-able. Pattern lock cho mọi resource: user_repo, order_repo, cart_repo, ...

Tradeoff: Arc<dyn Trait> dynamic dispatch (vtable lookup) thay vì static dispatch — overhead ~1-2 ns/call so với generic. Cho HTTP handler (vài microsecond/request) hoàn toàn negligible. Benefit testability gấp nhiều lần overhead.

8

Refactor list_products Dùng State

Apply B28 vào Shop API — refactor handler list_products trong crates/shop-api/src/routes/products.rs từ chỉ nhận Query<Pagination> (B23) sang nhận thêm State<AppState> ở đầu arg list. Sub-router signature đã là Router<AppState> sẵn (B17 + B23 + B24), không cần đổi.

// File: crates/shop-api/src/routes/products.rs
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use serde_json::{json, Value};
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),
        )
}

// REFACTOR B28: thêm State<AppState> đầu arg list
async fn list_products(
    State(state): State<AppState>,
    Query(pagination): Query<Pagination>,
) -> Json<Value> {
    tracing::info!(
        port = state.config.port,
        page = pagination.page,
        size = pagination.size,
        "listing products"
    );
    // G7 (B64) sẽ thay bằng: sqlx query state.pool
    let response = ListResponse::<Value>::new(vec![], 0, &pagination);
    Json(serde_json::to_value(response).unwrap())
}

// 7 handler còn lại GIỮ NGUYÊN (sẽ refactor State khi G7 implement thật)
async fn create_product() -> (StatusCode, Json<Value>) { /* ... */ }
async fn list_popular() -> Json<Value> { /* ... */ }
async fn get_product(Path(slug): Path<String>) -> Json<Value> { /* ... */ }
async fn replace_product(Path(slug): Path<String>) -> Json<Value> { /* ... */ }
async fn update_product(Path(slug): Path<String>) -> Json<Value> { /* ... */ }
async fn delete_product(Path(_slug): Path<String>) -> StatusCode { /* ... */ }
async fn get_related_product(Path((slug, related_slug)): Path<(String, String)>) -> Json<Value> { /* ... */ }

Import cần thêm vào đầu file: use axum::extract::State;use crate::state::AppState; (đã có từ routes() signature).

Verify qua cargo run -p shop-api + curl:

cargo run -p shop-api

# Terminal khác:
curl http://localhost:3000/api/v1/products
# {"items":[],"total":0,"page":1,"size":20,"hasNext":false}

curl 'http://localhost:3000/api/v1/products?page=2&size=50'
# {"items":[],"total":0,"page":2,"size":50,"hasNext":false}

Log server thêm field port=3000 từ state.config.port — bằng chứng handler thực sự đọc state:

# Server log (structured tracing JSON ở prod, pretty ở dev)
2026-06-14T10:00:00Z  INFO shop_api: listing products port=3000 page=1 size=20
2026-06-14T10:00:05Z  INFO shop_api: listing products port=3000 page=2 size=50

Suggested commit: B28: refactor list_products dùng State<AppState> extractor.

7 handler còn lại (create_product, list_popular, get_product, replace_product, update_product, delete_product, get_related_product) tạm GIỮ NGUYÊN — sẽ refactor thêm State khi G7 implement business logic thật (B62-B67) vì lúc đó handler mới thực sự cần access state.pool query DB.

9

Tổng Kết

  • AppState = shared resource container cross-handler (config, pool, redis, metric, enforcer). Khởi tạo 1 lần ở bootstrap app.rs.
  • Pattern: #[derive(Clone)] bắt buộc + Arc internal cho field lớn để clone-cheap. axum yêu cầu T: Clone + Send + Sync + 'static.
  • Router::with_state(state) ở cuối chain provide state cho mọi handler, resolve Router<AppState>Router<()> sẵn sàng cho axum::serve.
  • Sub-router define Router<AppState> generic over state — hoãn lock state đến phút cuối ở router.rs, KHÔNG tự gọi .with_state trong sub-router.
  • Handler nhận State<AppState> extractor đặt đầu arg list theo convention B13 (trước Path/Query/Json).
  • PgPool (sqlx) và RedisPool (deadpool-redis) đã có Arc internal — clone trực tiếp, KHÔNG wrap thêm Arc<PgPool> dư thừa. Struct read-only như AppConfig mới wrap Arc<T>.
  • Extend incremental theo lịch: G6 (B56) thêm pool: PgPool, G18 (B184) thêm redis: RedisPool, G15 (B152) thêm metric handle, G14 (B135) thêm Casbin enforcer.
  • Pattern test với mock state: trait abstract ProductRepo + field Arc<dyn ProductRepo> trong AppState + Mock impl cho unit test fast không touch DB. Chi tiết B256.
  • So sánh State<T> (static typed, compile-time, shared resource) vs Extension<T> (dynamic, runtime, request-scoped middleware data B39).
  • Shop API: list_products đã refactor dùng State<AppState> + Query<Pagination> log thêm state.config.port — skeleton sẵn sàng cho G7 (B64) thay vec![], 0 bằng sqlx::query! trên state.pool.
10

Bài Tập Củng Cố

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

  1. Vì sao field lớn trong AppState phải wrap Arc<T>? Vấn đề gì xảy ra nếu không wrap?
  2. Router::with_state(state) đổi type signature Router như thế nào? Tại sao đặt ở cuối builder chain?
  3. Sub-router trong routes/products.rs trả về Router<AppState> generic. Tại sao KHÔNG trả Router<()> sau khi tự gọi .with_state ngay trong sub-router?
  4. Handler arg State<AppState> nên đặt thứ tự nào trong arg list? Vì sao convention Shop API đặt đầu thay vì cuối?
  5. Pattern test với mock state cần trait abstract ProductRepo. Vì sao không dùng thẳng PostgresProductRepo với test DB cho đơn giản?
Đáp án
  1. Field lớn wrap Arc<T> vì axum yêu cầu state impl Clone và clone state cho mỗi request để pass vào extractor. Clone Arc<T> chỉ tăng atomic reference counter (vài nano-second), không copy bytes của T. Ngược lại clone AppConfig trực tiếp sẽ copy hàng chục field String + nested struct → mỗi request tốn vài microsecond CPU + alloc heap mới, throughput drop đáng kể dưới load cao. Vấn đề cụ thể nếu không wrap: (a) memory overhead — N concurrent request × M bytes config = N×M bytes heap alloc per request; (b) CPU overhead — memcpy + drop cho mỗi clone; (c) heap fragmentation lâu dài. Lưu ý phụ: PgPool/RedisPool/reqwest::Client đã có Arc internal nên KHÔNG wrap thêm — wrap thành Arc<PgPool> là double-Arc dư thừa, dereference qua 2 lớp. Confirm bằng cách đọc source: pub struct Pool<DB>(Arc<PoolInner<DB>>) trong sqlx-core.
  2. .with_state(state) resolve type signature Router<AppState>Router<()> bằng cách "lock in" state. Trước khi gọi, router có generic S = AppState chưa được consume; sau khi gọi, state đã được consume vào internal storage của router, type generic S trở về () default. Hệ quả: axum::serve(listener, app) yêu cầu Router<()> — KHÔNG truyền được Router<AppState> chưa consume state. Đặt ở cuối builder chain vì: (a) mọi .route(), .merge(), .nest() phía trên đều có thể đăng ký handler dùng State<AppState>, compiler check bound trên Router<AppState> nên phải giữ generic đến khi tất cả route được khai báo xong; (b) fallbackmethod_not_allowed_fallback handler cũng có thể dùng State — đặt trước .with_state đảm bảo type signature consistent; (c) .with_state consume self + state ownership → sau dòng này không thể chain method nào yêu cầu Router<AppState> nữa, đặt cuối tránh compile error.
  3. Sub-router KHÔNG tự gọi .with_state trong routes/products.rs3 lý do thiết kế: (a) Separation of concerns — sub-router chỉ biết về route declaration + handler logic, KHÔNG biết về state instance (state có ở bootstrap app.rs sau khi load config + init pool). Sub-router không nên import AppConfig::from_env() để tự build state. (b) Testability — nếu sub-router tự lock state, test sub-router riêng phải build full state với mọi field (pool, redis, metric) dù test chỉ cần subset. Giữ generic Router<AppState> cho phép test build state custom với mock impl. (c) Reusability — pattern multi-version (G3+ có thể có /api/v1 + /api/v2 song song) dùng cùng sub-router factory mount 2 prefix khác nhau với cùng state. Nếu sub-router lock state sẵn → không reusable. Pattern lock B17: state lock duy nhất ở router.rs dòng cuối .with_state(state), sub-router LUÔN trả Router<AppState>.
  4. State<T> impl FromRequestParts (không consume body) → technically đặt vị trí nào cũng work, nhưng convention Shop API (lock B13) đặt đầu với 4 lý do: (a) Readability — đọc signature từ trái sang phải biết ngay handler cần resource gì, sau đó mới đến input request (Path/Query/Json). Pattern giống "dependencies first" trong constructor injection. (b) Consistency cross-handler — mọi handler trong Shop API có cùng pattern State, Path, Query, Extension, Json, code review nhanh, refactor an toàn. (c) Compiler error message clearer — khi State extract fail (state type mismatch lúc compile), error point vào dòng đầu signature dễ debug hơn dòng cuối. (d) Convention từ web framework khác — Spring (@Autowired first), NestJS (@Inject first), Django (request first nhưng request không là dep, dep đứng sau request) — quy ước "shared dep first, request data after" phổ biến giúp developer chuyển stack quen thuộc. KHÔNG đặt cuối: cuối là vị trí cho body extractor (Json<T>, Form<T>, Bytes) vì body consume one-shot phải ở cuối — đặt State đó sẽ phá convention.
  5. Trait abstract ProductRepo cần thiết vì 4 lý do: (a) Test speed — unit test với mock impl chạy < 1 ms/test, không có network/IO. Với PostgresProductRepo + testcontainers spin up Postgres mất ~5s/test (container start + migration run), full test suite 100 test = 500s = 8 phút → CI slow + dev không chạy test local thường xuyên. Mock 100 test = < 1s. (b) Test isolation — testcontainers shared instance có flaky risk (port conflict, container crash, data leak between test). Mock impl in-memory hoàn toàn isolate, không có state cross-test. (c) Test edge case dễ — mock có thể trả về Err(Error::DbConnectionLost) để test handler error path, trả Vec với 10000 element để test pagination boundary, trả product với field null kiểu cụ thể để test serialize. Test với real DB phải seed data + trigger error qua DB-level pattern phức tạp. (d) Domain decoupling từ infrastructure — trait ProductRepo trong shop-core (pure domain crate), impl PostgresProductRepo trong shop-db (infrastructure). Tách bạch domain khỏi infrastructure cho phép swap PostgreSQL → MySQL → MongoDB chỉ bằng impl mới của trait, không sửa handler/service. Đây là Clean Architecture / Hexagonal applied. Tradeoff: dynamic dispatch Arc<dyn Trait> overhead ~1-2 ns/call (vtable lookup) — negligible cho HTTP handler vài microsecond/request. Lưu ý: vẫn có integration test với testcontainers chạy real DB cho test SQL logic + migration correctness, nhưng số lượng nhỏ (5-10 test) chạy nightly thay vì mọi PR.
11

Bài Tiếp Theo

— chi tiết .layer(layer) áp dụng mọi route vs .route_layer(layer) chỉ route khai báo trên, use case auth chỉ một số route, ordering layer trong build_router.