Danh sách bài viết

Bài 73: Domain Error Pattern Unified — thiserror + From Mapping

Bài 73 của series Rust RESTful API — bài CODE thực tế nối tiếp B72 (init crate shop-core chứa 5 service trait + impl + dependency injection AppState + handler thin 3-5 dòng + mock test pattern) tách domain error ra khỏi AppError HTTP-coupled: phân biệt 2 anti-pattern phổ biến — AppError-everywhere (service layer return AppError trực tiếp = couple chặt với HTTP status code + envelope) vs anyhow-everywhere (service return anyhow::Result<T> = mất type information, caller phải downcast) → solution pattern 3 layer error lock vĩnh viễn: sqlx::Error (repository) → DomainError (service) → AppError (handler/HTTP); thiserror crate MANDATORY cho domain error (5 enum type-safe library code) + anyhow chỉ ở main.rs (top-level startup convenience opaque wrapper); refactor 5 service trait B72 return type từ Result<DTO, AppError> sang Result<DTO, DomainError> — semantic rõ ràng per bounded context; #[error(transparent)] + #[from] sqlx::Error auto-convert pattern lock cho mọi domain error wrap infrastructure error; orphan rule Rust giải thích tại sao impl From<DomainError> for AppError đặt ở crates/shop-api/src/error_map.rs (boundary layer) thay shop-common (sẽ circular dep vì shop-core đã depend shop-common); 5 impl From centralized 1 file dễ review variant mới; handler dùng ? operator auto convert DomainError → AppError code KHÔNG đổi nhiều; variant categorization 3 nhóm — Infrastructure (Sqlx, Stripe wrap third-party), Domain (NotFound, Duplicate, Locked, Empty), Auth (InvalidCredentials, EmailNotVerified); AppError thêm Forbidden(String) variant 20 (19 → 20) phục vụ UserError::EmailNotVerified map 403 thay 401; PaymentError::Stripe → Internal 500 log chi tiết Stripe internal KHÔNG expose payment provider message ra client; UserError::InvalidCredentials → 401 Unauthorized KHÔNG 404 — auth context security pattern (KHÔNG leak "email tồn tại nhưng password sai"); foundation cho B74 (CRUD macro derive proc macro generate boilerplate), B75 (end-to-end integration test full Shop API + mock service + real DB testcontainer), G15 (mail service error mapping cùng pattern).

16/06/2026
12 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 2 anti-pattern phổ biến: AppError-everywhere (service couple HTTP) vs anyhow-everywhere (mất type information).
  • Hiểu pattern domain error enum + transparent sqlx wrap qua #[error(transparent)] + #[from].
  • Refactor 5 domain error: ProductError, OrderError, PaymentError, CartError, UserError.
  • Apply pattern explicit From<DomainError> for AppError centralized trong 1 file boundary layer.
  • Hiểu thiserror vs anyhow trade-off — khi nào chọn cái nào.
  • Service trait B72 giờ return Result<DTO, DomainError> thay Result<DTO, AppError> — semantic rõ ràng per bounded context.
  • Handler convert DomainError → AppError qua ? operator auto — code KHÔNG đổi nhiều.
  • Hiểu orphan rule Rust — tại sao impl From đặt ở shop-api thay shop-common.
  • Thêm AppError::Forbidden(String) variant 20 (19 → 20) cho EmailNotVerified scenario.
2

2 Anti-Pattern Error Trong Rust API

Sau B72, 5 service trait đang return Result<DTO, AppError> trực tiếp. Pattern này hoạt động, nhưng vi phạm separation of concerns. Trước khi refactor, cần nhận diện 2 anti-pattern thường thấy trong codebase Rust API.

Anti-pattern 1 — AppError-everywhere: service layer return AppError trực tiếp (đang là state Shop API sau B72). Vấn đề:

  • AppErrorHTTP-coupled — chứa status code mapping (NotFound → 404, Validation → 422), envelope JSON format, header (Retry-After, WWW-Authenticate). Service layer đáng lẽ chỉ biết business logic, không nên biết status code.
  • Khó test — service test phải import shop-common::error + assert variant HTTP cụ thể; thay vì assert "không tìm thấy product slug X" thì phải assert "AppError::NotFound".
  • Khó reuse cross-binaryshop-worker (G15) + shop-cli (G16) gọi service nhưng KHÔNG cần HTTP envelope; kéo theo AppError mang dependency thừa.

Anti-pattern 2 — anyhow-everywhere: service return anyhow::Result<T> opaque wrapper. Vấn đề:

  • Mất type information — caller không biết error nào (NotFound? Validation? Conflict?) nên không map đúng status code.
  • Caller phải downcast qua err.downcast_ref::<sqlx::Error>() chuỗi — clunky + fragile khi refactor.
  • Compiler KHÔNG enforce handle case mới — variant thêm không break build, dễ miss handling.

Solution pattern B73 lock vĩnh viễn — 3 layer error tách rõ:

Layer            | Error type           | Crate         | Knows about
-----------------+----------------------+---------------+----------------------
Repository       | sqlx::Error          | shop-db       | Postgres protocol
Service          | DomainError          | shop-core     | Business logic
Handler          | AppError             | shop-common   | HTTP status + envelope

Mỗi layer chỉ biết error type của chính nó. Conversion qua ? operator + impl From:

  • Repository trả sqlx::Error → service dùng ? auto convert sang DomainError::Sqlx qua #[from].
  • Service trả DomainError → handler dùng ? auto convert sang AppError qua impl From<DomainError>.

Pattern này tách rõ ranh giới: service layer KHÔNG biết status code 404/422, chỉ biết "product không tồn tại" hoặc "stock không đủ". HTTP mapping nằm ở boundary layer shop-api.

3

thiserror Vs anyhow Trade-Off

Rust ecosystem có 2 crate error handling chính, mục đích khác nhau hoàn toàn — KHÔNG thay thế lẫn nhau.

thiserror crate (David Tolnay) — macro #[derive(Error)] generate impl std::error::Error + Display cho enum hoặc struct. Type-safe — caller biết chính xác variant nào. Use case: domain error trong library code (service trait, repository pattern).

// thiserror — type-safe enum, caller match variant
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ProductError {
    #[error("product not found: {0}")]
    NotFound(String),

    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),
}

anyhow crate (cũng David Tolnay) — anyhow::Error opaque wrapper xóa type cụ thể, dùng cho code KHÔNG cần phân biệt error type. Use case: application top-level error (main.rs, prototype, script).

// anyhow — opaque wrapper, dùng cho main.rs
use anyhow::{Context, Result};

#[tokio::main]
async fn main() -> Result<()> {
    let config = AppConfig::from_env()
        .context("failed to load app config")?;     // any error type
    let pool = build_pool(&config.database_url).await
        .context("failed to connect database")?;    // chained context
    // ...
    Ok(())
}

Lock decision Shop API B73 (vĩnh viễn):

  • thiserror cho 5 domain error enum (ProductError + OrderError + PaymentError + CartError + UserError) — library code cần type-safe.
  • anyhow chỉ ở crates/shop-api/src/main.rs startup logic — wire pool + config + log init không cần phân biệt error.
  • AppError giữ ở shop-common::error — HTTP-coupled enum, dùng cho IntoResponse + handler boundary.

Anti-pattern cần tránh: dùng anyhow::Error trong service trait — caller mất khả năng phân biệt "không tìm thấy" vs "validation fail" vs "DB error" → status code mapping về 500 hết.

4

Refactor ProductError Pattern Standard

Bắt đầu với ProductError làm chuẩn mẫu cho 4 service còn lại. Extend file crates/shop-core/src/products.rs (đã có từ B72) thêm enum domain error:

// File: crates/shop-core/src/products.rs (extend B72)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ProductError {
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),

    #[error("product {0} not found")]
    NotFound(String),

    #[error("product slug {0} already exists")]
    SlugAlreadyExists(String),

    #[error("validation failed: {0}")]
    Validation(String),

    #[error("product {0} is soft-deleted, cannot perform action")]
    SoftDeleted(String),
}

3 attribute đáng chú ý:

  • #[error(transparent)] — forward Display + source() sang inner error. Khi caller log ProductError::Sqlx(err), message hiển thị giống hệt sqlx::Error không thêm wrapping noise.
  • #[from] sqlx::Error — generate impl From<sqlx::Error> for ProductError tự động. Trong service body chỉ cần ? operator, không phải .map_err(ProductError::Sqlx) dài dòng.
  • #[error("...")] — format Display string. Placeholder {0} truy cập field tuple, {field_name} truy cập field struct.

Refactor ProductService trait B72 return type từ AppError sang ProductError:

// File: crates/shop-core/src/products.rs (refactor B72 trait)
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait ProductService: Send + Sync {
    async fn create(
        &self,
        dto: CreateProductDto,
        actor: Option<i64>,
        request_id: Option<&str>,
    ) -> Result<ProductResponseDto, ProductError>;

    async fn find_by_slug(
        &self,
        slug: &str,
    ) -> Result<Option<ProductResponseDto>, ProductError>;

    async fn search(
        &self,
        query: ProductSearchQuery,
    ) -> Result<ProductListResponse, ProductError>;

    async fn soft_delete(
        &self,
        slug: &str,
        actor: Option<i64>,
        request_id: Option<&str>,
    ) -> Result<(), ProductError>;
}

Implementation PgProductService dùng ? operator auto convert sqlx::Error → ProductError::Sqlx qua #[from]:

// File: crates/shop-core/src/products.rs (refactor impl)
#[async_trait]
impl ProductService for PgProductService {
    async fn find_by_slug(
        &self,
        slug: &str,
    ) -> Result<Option<ProductResponseDto>, ProductError> {
        // `?` auto convert sqlx::Error → ProductError::Sqlx qua #[from]
        let row = repo::find_by_slug(&self.pool, slug, false).await?;
        Ok(row.map(ProductResponseDto::from))
    }

    async fn soft_delete(
        &self,
        slug: &str,
        _actor: Option<i64>,
        _request_id: Option<&str>,
    ) -> Result<(), ProductError> {
        // Business rule: check exists trước khi soft_delete
        let row = repo::find_by_slug(&self.pool, slug, true).await?
            .ok_or_else(|| ProductError::NotFound(slug.to_string()))?;

        if row.deleted_at.is_some() {
            return Err(ProductError::SoftDeleted(slug.to_string()));
        }

        repo::soft_delete(&self.pool, slug).await?;
        Ok(())
    }
}

Lưu ý: business rule "không soft_delete record đã soft_deleted" giờ trả ProductError::SoftDeleted rõ semantic, không phải AppError::Validation generic. Layer trên (handler) mới quyết định map 422 hay 409.

5

impl From<ProductError> for AppError — Centralized Mapping

Câu hỏi đầu tiên: đặt impl From<ProductError> for AppError ở crate nào? Naive answer: shop-common::errorAppError ở đó. Nhưng vướng circular dependency:

Current dep graph (B72 lock):
shop-api → shop-core → shop-db → shop-common

Nếu shop-common impl From:
shop-common → shop-core → shop-common  ❌ CYCLIC

shop-core đã depend shop-common (để dùng DTO + AppError trước B73); nếu shop-common ngược lại depend shop-core để import ProductError → Cargo báo lỗi cyclic.

Solution qua orphan rule Rust: chỉ được impl trait cho type nếu trait HOẶC type ở cùng crate với impl block. Đặt impl ở 1 crate khác — miễn là crate đó có quyền truy cập cả AppErrorProductError trong dep tree.

Lock decision: tạo crates/shop-api/src/error_map.rs chứa 5 impl From centralized. shop-api nằm cao nhất dep graph, biết cả shop-core (domain error) và shop-common (AppError) → orphan rule OK vì shop-api "sở hữu" boundary HTTP.

// File: crates/shop-api/src/error_map.rs (NEW B73)
use shop_common::error::AppError;
use shop_core::products::ProductError;

impl From<ProductError> for AppError {
    fn from(err: ProductError) -> Self {
        match err {
            // Delegate sqlx::Error mapping cho B55 helper map_db_error
            // qua existing impl From for AppError
            ProductError::Sqlx(e) => AppError::from(e),

            ProductError::NotFound(slug) => {
                AppError::NotFound(format!("product {} not found", slug))
            }

            ProductError::SlugAlreadyExists(slug) => {
                AppError::Conflict(format!("product slug {} already exists", slug))
            }

            ProductError::Validation(msg) => AppError::Validation(msg),

            ProductError::SoftDeleted(slug) => {
                AppError::Validation(format!("product {} is soft-deleted", slug))
            }
        }
    }
}

Update crates/shop-api/src/main.rs import module:

// File: crates/shop-api/src/main.rs (extend B72)
mod config;
mod error_map;   // B73 — đăng ký impl From cho 5 domain error
mod routes;
mod state;

Pattern này tận dụng được impl From<sqlx::Error> for AppError đã có từ B55 (delegate qua map_db_error helper). ProductError::Sqlx(e) => AppError::from(e) chỉ là 1 dòng — đẩy mọi SQLSTATE mapping (23505 Conflict, 23503 ForeignKeyViolation, 23514 CheckViolation, ...) về 1 nơi B55 đã viết.

Lock pattern: 1 file error_map.rs chứa tất cả 5 impl From — review variant mới chỉ cần grep 1 file. Khi service mới thêm (G15 NotificationService, G14 InventoryService), append 1 impl From mới vào cùng file.

6

5 Domain Error Đầy Đủ

Áp pattern ProductError cho 4 service còn lại trong shop-core. Mỗi domain error reflect bounded context (DDD) của service tương ứng.

// File: crates/shop-core/src/orders.rs (extend B72)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum OrderError {
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),

    #[error("order {0} not found")]
    NotFound(i64),

    #[error("product {0} not found")]
    ProductNotFound(i64),

    #[error("insufficient stock for product {product_id}: requested {requested}, available {available}")]
    InsufficientStock {
        product_id: i64,
        requested: i32,
        available: i32,
    },

    #[error("invalid state transition from {from} to {to}")]
    InvalidTransition { from: String, to: String },

    #[error("order {0} cannot be modified")]
    Locked(i64),
}
// File: crates/shop-core/src/payments.rs (extend B72)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum PaymentError {
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),

    #[error(transparent)]
    Stripe(#[from] stripe::StripeError),

    #[error("payment intent {0} not found")]
    IntentNotFound(String),

    #[error("webhook signature invalid")]
    WebhookSignatureInvalid,

    #[error("order {0} payment already exists")]
    Duplicate(i64),
}
// File: crates/shop-core/src/carts.rs (extend B72)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum CartError {
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),

    #[error("cart {0} not found")]
    NotFound(i64),

    #[error("cart item {0} not found")]
    ItemNotFound(i64),

    #[error("product {0} insufficient stock for cart")]
    InsufficientStock(i64),

    #[error("cart empty, cannot checkout")]
    Empty,
}
// File: crates/shop-core/src/users.rs (extend B72)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserError {
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),

    #[error("user {0} not found")]
    NotFound(i64),

    #[error("email {0} already registered")]
    EmailExists(String),

    #[error("invalid credentials")]
    InvalidCredentials,

    #[error("verification token {0} invalid or expired")]
    InvalidVerificationToken(String),

    #[error("user {0} email not verified")]
    EmailNotVerified(i64),
}

Variant categorization 3 nhóm lock cho mọi domain error tương lai:

  • InfrastructureSqlx, Stripe: wrap third-party error qua #[error(transparent)] + #[from]. Auto convert qua ? operator. Mapping cuối cùng về AppError::Internal hoặc deferred B55 logic.
  • DomainNotFound, Duplicate, Locked, Empty, InsufficientStock, InvalidTransition: business rule vi phạm. Caller (handler) map theo semantic — 404/409/422 tùy ngữ cảnh.
  • AuthenticationInvalidCredentials, EmailNotVerified, InvalidVerificationToken: auth-specific. Map 401/403 + KHÔNG leak chi tiết (security pattern).

5 domain error mỗi service tách rõ bounded context — caller match được variant cụ thể thay vì xài 1 enum chung cho cả app (anti-pattern god-enum).

7

Mapping Per Error → AppError

Trước khi viết 4 impl còn lại, thêm variant Forbidden(String) vào AppError (bump 19 → 20). B10 đã reserve Forbidden unit variant cho 403, nhưng cần text message detail cho EmailNotVerified scenario:

// File: crates/shop-common/src/error.rs (extend B55)
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    // ... 19 variant cũ B10 + B41 + B48 + B55
    #[error("forbidden: {0}")]
    Forbidden(String),     // B73 — variant 20, map 403
}

impl AppError {
    fn status_code(&self) -> StatusCode {
        match self {
            // ... 19 mapping cũ
            AppError::Forbidden(_) => StatusCode::FORBIDDEN,   // 403
        }
    }

    fn code(&self) -> &'static str {
        match self {
            // ... 19 code cũ
            AppError::Forbidden(_) => "FORBIDDEN",
        }
    }
}

Note: variant cũ Forbidden unit (B10) deprecate dần — handler tương lai dùng Forbidden(String) phong phú hơn. B73 cập nhật match arm trong IntoResponse + helper status_code() + code() theo pattern B48/B55 (compiler enforce add mapping mới qua exhaustive match).

Hoàn chỉnh crates/shop-api/src/error_map.rs với 5 impl:

// File: crates/shop-api/src/error_map.rs (full B73)
use shop_common::error::AppError;
use shop_core::{
    products::ProductError,
    orders::OrderError,
    payments::PaymentError,
    carts::CartError,
    users::UserError,
};

impl From<OrderError> for AppError {
    fn from(err: OrderError) -> Self {
        match err {
            OrderError::Sqlx(e) => AppError::from(e),
            OrderError::NotFound(id) => {
                AppError::NotFound(format!("order {} not found", id))
            }
            OrderError::ProductNotFound(id) => {
                AppError::NotFound(format!("product {} not found", id))
            }
            OrderError::InsufficientStock { product_id, requested, available } => {
                AppError::Validation(format!(
                    "product {} stock {} < requested {}",
                    product_id, available, requested
                ))
            }
            OrderError::InvalidTransition { from, to } => {
                AppError::Validation(format!("invalid transition from {} to {}", from, to))
            }
            OrderError::Locked(id) => {
                AppError::Conflict(format!("order {} locked", id))
            }
        }
    }
}

impl From<PaymentError> for AppError {
    fn from(err: PaymentError) -> Self {
        match err {
            PaymentError::Sqlx(e) => AppError::from(e),
            PaymentError::Stripe(e) => {
                // KHÔNG expose Stripe internal — log chi tiết, trả generic 500
                tracing::error!(?e, "stripe API error");
                AppError::Internal(anyhow::anyhow!("payment provider error"))
            }
            PaymentError::IntentNotFound(id) => {
                AppError::NotFound(format!("payment intent {} not found", id))
            }
            PaymentError::WebhookSignatureInvalid => {
                AppError::BadRequest("invalid webhook signature".into())
            }
            PaymentError::Duplicate(order_id) => {
                AppError::Conflict(format!("payment for order {} already exists", order_id))
            }
        }
    }
}

impl From<CartError> for AppError {
    fn from(err: CartError) -> Self {
        match err {
            CartError::Sqlx(e) => AppError::from(e),
            CartError::NotFound(id) => {
                AppError::NotFound(format!("cart {} not found", id))
            }
            CartError::ItemNotFound(id) => {
                AppError::NotFound(format!("cart item {} not found", id))
            }
            CartError::InsufficientStock(id) => {
                AppError::Validation(format!("product {} insufficient stock", id))
            }
            CartError::Empty => {
                AppError::Validation("cart empty, cannot checkout".into())
            }
        }
    }
}

impl From<UserError> for AppError {
    fn from(err: UserError) -> Self {
        match err {
            UserError::Sqlx(e) => AppError::from(e),
            UserError::NotFound(id) => {
                AppError::NotFound(format!("user {} not found", id))
            }
            UserError::EmailExists(email) => {
                AppError::Conflict(format!("email {} already registered", email))
            }
            // 401 — KHÔNG 404 vì auth context security
            UserError::InvalidCredentials => {
                AppError::Unauthenticated
            }
            UserError::InvalidVerificationToken(_) => {
                AppError::BadRequest("invalid or expired verification token".into())
            }
            // 403 — yêu cầu xác thực email mới được dùng tính năng
            UserError::EmailNotVerified(_) => {
                AppError::Forbidden("email not verified".into())
            }
        }
    }
}

2 quyết định security đáng chú ý:

  • PaymentError::Stripe → AppError::Internal — KHÔNG expose stripe::StripeError message ra client. Stripe error có thể chứa internal account info, API key fragment, rate-limit detail; log đầy đủ qua tracing::error! cho ops debug, client chỉ nhận "payment provider error" generic.
  • UserError::InvalidCredentials → AppError::Unauthenticated (401) — KHÔNG map 404 NotFound dù internal có thể biết "email không tồn tại". Lý do: 404 sẽ leak thông tin "email X đã đăng ký hay chưa" cho attacker enumerate user database. Pattern security: trả 401 generic cho cả 2 case (email sai + password sai).

Verify compile + test toàn workspace sau refactor:

cargo build --workspace
# → Compiling shop-common ... shop-core ... shop-api
# → Finished `dev` profile [unoptimized + debuginfo] target(s)

# Chạy test domain error mapping
cargo test -p shop-api error_map
8

Refactor Handler — Implicit ? Convert Domain → App

Handler sau B72 đã thin 3-5 dòng gọi state.<service>.method(). Sau B73, code handler KHÔNG đổi nhiều — chỉ là kiểu error trong signature chain qua ? giờ auto convert DomainError → AppError qua impl From.

// File: crates/shop-api/src/routes/products.rs (sau B73)
use axum::{extract::State, Json};
use shop_common::error::AppError;
use shop_common::dto::ProductResponseDto;
use crate::extractors::AppPath;
use crate::state::AppState;

pub async fn get_product(
    State(state): State<AppState>,
    AppPath(slug): AppPath<String>,
) -> Result<Json<ProductResponseDto>, AppError> {
    // state.product_service.find_by_slug(...) trả Result, ProductError>
    // `?` sau await: ProductError → AppError qua impl From (B73)
    // .ok_or_else: Option → Result
    let dto = state.product_service.find_by_slug(&slug).await?
        .ok_or_else(|| AppError::NotFound(format!("product {} not found", slug)))?;
    Ok(Json(dto))
}

pub async fn soft_delete_product(
    State(state): State<AppState>,
    AppPath(slug): AppPath<String>,
) -> Result<StatusCode, AppError> {
    // `?` convert ProductError::NotFound → AppError::NotFound qua impl From
    // `?` convert ProductError::SoftDeleted → AppError::Validation qua impl From
    state.product_service.soft_delete(&slug, None, None).await?;
    Ok(StatusCode::NO_CONTENT)
}

Code handler vẫn 3-5 dòng. Khác biệt là layer error đã clean separation:

  • Service biết "product không tồn tại" (semantic), KHÔNG biết status 404 (HTTP).
  • Handler biết "404 NotFound" (HTTP), nhận semantic qua ProductError.
  • Conversion ở 1 nơi (error_map.rs) — review dễ, thay đổi 1 lần áp toàn app.

Verify với mock service trả error variant cụ thể, handler trả status đúng:

// File: crates/shop-api/tests/handlers_test.rs (extend B72)
#[tokio::test]
async fn get_product_soft_deleted_returns_422() {
    let mut mock = MockProductService::new();

    // Service trả ProductError::SoftDeleted
    mock.expect_soft_delete()
        .with(eq("deleted-slug"), eq(None), eq(None))
        .times(1)
        .returning(|slug, _, _| {
            Err(ProductError::SoftDeleted(slug.to_string()))
        });

    let state = test_app_state_with_product_service(Arc::new(mock));

    let resp = call_soft_delete(state, "deleted-slug").await;

    // Handler ? convert ProductError::SoftDeleted → AppError::Validation → 422
    assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
cargo test -p shop-api --test handlers_test get_product_soft_deleted

Pattern test này confirm: thay đổi mapping (vd map SoftDeleted → Conflict 409 thay vì 422) chỉ cần sửa 1 dòng trong error_map.rs — handler không sửa, test cover regression.

9

Tổng Kết

  • 2 anti-pattern: AppError-everywhere (service couple HTTP) vs anyhow-everywhere (mất type information, không map status).
  • Pattern lock B73: 3 layer error — sqlx::Error (repo) → DomainError (service) → AppError (handler/HTTP).
  • thiserror cho domain error (type-safe library code, 5 enum); anyhow chỉ ở main.rs top-level startup.
  • 5 domain error: ProductError, OrderError, PaymentError, CartError, UserError — 1 enum per bounded context (DDD).
  • Variant categorization: Infrastructure (Sqlx, Stripe) + Domain (NotFound, Duplicate, Empty, ...) + Auth (InvalidCredentials, EmailNotVerified).
  • #[error(transparent)] + #[from] auto-convert sqlx::Error → DomainError::Sqlx qua ? operator.
  • Orphan rule Rust — impl From<DomainError> for AppError đặt ở shop-api (boundary), KHÔNG shop-common (circular dep).
  • crates/shop-api/src/error_map.rs centralized 5 impl From — 1 nơi review variant mới.
  • Handler dùng ? operator — auto convert DomainError → AppError, code không đổi nhiều.
  • PaymentError::Stripe → AppError::Internal 500 — log chi tiết, KHÔNG expose Stripe internal ra client.
  • UserError::InvalidCredentials → AppError::Unauthenticated 401 — KHÔNG 404 vì auth context security (không leak user enumeration).
  • AppError::Forbidden(String) variant 20 mới (19 → 20) phục vụ UserError::EmailNotVerified map 403.
  • File path lock B73: extend 5 module trong shop-core + NEW crates/shop-api/src/error_map.rs + update main.rs + extend shop-common::error::AppError thêm Forbidden(String).
  • Service trait B72 refactor return type từ Result<DTO, AppError> sang Result<DTO, DomainError> — semantic rõ ràng per bounded context.
  • Foundation cho B74 (CRUD macro derive proc macro), B75 (full integration test), G15 (mail service error mapping cùng pattern).
10

Bài Tập Củng Cố

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

  1. 2 anti-pattern AppError-everywhere vs anyhow-everywhere — pros/cons mỗi cách? Solution layered là gì và tại sao tốt hơn cả 2?
  2. thiserror vs anyhow — khi nào chọn mỗi crate? Cho ví dụ scenario library code vs binary main.rs.
  3. Orphan rule Rust — tại sao impl From<ProductError> for AppError đặt ở shop-api thay shop-common? Mô tả scenario circular dep cụ thể.
  4. #[error(transparent)] + #[from] auto convert pattern — cho ví dụ với sqlx::Error wrap ProductError::Sqlx; điều gì xảy ra nếu thiếu #[from]?
  5. UserError::InvalidCredentials → 401 Unauthorized — tại sao KHÔNG map 404 NotFound? Giải thích auth context security pattern.
Đáp án
  1. 2 anti-pattern pros/cons + solution layered: AppError-everywhere (service return AppError trực tiếp). Pros: (i) đơn giản, ít boilerplate; (ii) handler nhận thẳng AppError không cần convert. Cons: (a) service couple HTTP — biết status code 404/422/409, biết envelope JSON, biết header (Retry-After, WWW-Authenticate); (b) khó test — phải import shop-common::error, assert variant HTTP-specific thay vì assert semantic; (c) không reuse cross-binaryshop-worker + shop-cli kéo theo HTTP dependency không cần; (d) violate Single Responsibility — 1 enum gánh cả business + HTTP. anyhow-everywhere (service return anyhow::Result<T>). Pros: (i) extremely simple, ? chain mọi error type; (ii) .context() chain debug info dễ. Cons: (a) mất type information — caller không biết error nào (NotFound? Validation?) → status code map về 500 hết; (b) downcast clunky — phải err.downcast_ref::<sqlx::Error>() chain, fragile khi refactor; (c) compiler không enforce handle case mới — variant thêm không break build, dễ miss. Solution layered B73 lock: 3 layer error tách rõ — sqlx::Error (repository wrap DB), DomainError (service biết business), AppError (handler biết HTTP). Mỗi layer chỉ biết error type của chính nó; conversion qua ? + impl From. Tại sao tốt hơn cả 2: (i) type-safe — thiserror enum compiler enforce match exhaustive; (ii) decoupled — service không biết HTTP, reuse cross-binary OK; (iii) centralized mapping — 1 file error_map.rs kiểm soát; (iv) testable — service test assert semantic variant, handler test assert status code; (v) zero ergonomic loss — ? operator vẫn ngắn gọn. Trade-off chấp nhận: thêm 5 enum + 5 impl From = ~100 dòng boilerplate cho codebase 5 service; nhỏ so với benefit. Generalize: pattern industry standard cho Rust web app medium+ (axum, actix-web, rocket community đều khuyến nghị). Reference: Luca Palmieri "Zero To Production in Rust" chương 8 error handling cùng pattern.
  2. thiserror vs anyhow — khi nào chọn: thiserror dùng cho library code cần type-safe error — caller match variant cụ thể, compiler enforce handle case. Use case: domain error enum trong service trait, repository pattern, public API library SDK. Ví dụ: ProductError trong shop-core::products để handler match NotFound vs SlugAlreadyExists map status khác nhau. Cú pháp: #[derive(Error)] enum X { #[error("msg")] Variant(field) }. Generate impl std::error::Error + Display + (nếu có #[from]) impl From<Inner> auto. Overhead: zero runtime — chỉ macro expand compile-time. anyhow dùng cho application top-level code không cần phân biệt error. anyhow::Error opaque wrapper bọc dyn std::error::Error + Send + Sync + 'static + dynamic backtrace. Use case: binary main() startup wire pool/config/log, prototype script, CLI command tool. Ví dụ: fn main() -> anyhow::Result<()> { let config = AppConfig::from_env().context("load config")?; let pool = build_pool(&config.db_url).await.context("connect db")?; Ok(()) } — caller chỉ cần biết "có lỗi" + chain context, không cần match variant. Lock decision Shop API B73: thiserror cho 5 domain error + AppError (đã dùng từ B10); anyhow chỉ ở main.rs startup. Anti-pattern: dùng anyhow::Error trong public library API — consumer mất khả năng phân biệt error type, fail fast khi cần map status code → tránh hoàn toàn cho service layer. 2 crate complementary KHÔNG mutually exclusive — cùng tác giả David Tolnay design intentional cho 2 use case khác nhau. Reference: BurntSushi blog "Error Handling in Rust" + Yoshua Wuyts "Error Patterns in Rust".
  3. Orphan rule + circular dep scenario: Orphan rule Rust (RFC 2451) — chỉ được impl trait cho type nếu HOẶC trait HOẶC type ở cùng crate với impl block. Mục đích: ngăn 2 crate khác nhau impl cùng (Trait, Type) pair tạo conflict ambiguity. Áp Shop API: AppErrorshop-common, ProductErrorshop-core. Impl From<ProductError> for AppError được phép đặt ở: shop-common (vì AppError ở đó), shop-core (vì ProductError ở đó), hoặc bất kỳ crate nào downstream của cả 2 (shop-api qua From trait ở std). Circular dep scenario nếu đặt ở shop-common: shop-common cần import shop_core::products::ProductErrorshop-common depend shop-core. Nhưng dep graph hiện tại shop-core → shop-common (shop-core dùng shop_common::dto, shop_common::error::AppError). Add ngược lại tạo cycle shop-common → shop-core → shop-common — Cargo báo lỗi cyclic dependency between crates, build fail. Workaround thử: tách AppError ra crate thứ 3 (vd shop-error) để cả 2 đều depend nó — vẫn không giải quyết vì conversion logic cần biết cả 2 type. Solution lock B73: đặt impl ở shop-api/src/error_map.rsshop-api nằm cao nhất dep graph (shop-api → shop-core → shop-db → shop-common), depend cả 2 type → orphan rule OK vì From trait ở std. Tại sao boundary layer hợp lý: (a) shop-api là layer biết HTTP — chỗ natural để map domain → HTTP; (b) shop-core không nên biết AppError (reuse cross-binary); (c) shop-common không nên biết DomainError (utility chung). Generalize: pattern này gọi là Boundary Adapter — conversion giữa layer đặt ở layer cao nhất biết cả 2. Áp tương lai: G15 thêm shop-mail crate với MailError → impl From<MailError> for AppError đặt ở shop-api/error_map.rs cùng nguyên tắc.
  4. #[error(transparent)] + #[from] auto convert pattern: 2 attribute riêng biệt kết hợp cho pattern wrap third-party error. #[error(transparent)] — forward Display + source() sang inner error. Khi caller log error, message hiển thị giống hệt inner không thêm wrapping noise (vd "error returned from database: ..." thay vì "ProductError::Sqlx(error returned from database: ...)"). #[from] — generate impl From<Inner> for Outer tự động qua thiserror macro expand. Ví dụ với sqlx::Error:
    #[derive(Debug, Error)]
    pub enum ProductError {
        #[error(transparent)]
        Sqlx(#[from] sqlx::Error),
    
        #[error("product {0} not found")]
        NotFound(String),
    }
    Macro generate code tương đương:
    // Macro expanded code (auto-generated):
    impl From<sqlx::Error> for ProductError {
        fn from(source: sqlx::Error) -> Self {
            ProductError::Sqlx(source)
        }
    }
    
    impl Display for ProductError {
        fn fmt(&self, f: &mut Formatter) -> fmt::Result {
            match self {
                ProductError::Sqlx(e) => e.fmt(f),    // transparent forward
                ProductError::NotFound(s) => write!(f, "product {} not found", s),
            }
        }
    }
    
    impl std::error::Error for ProductError {
        fn source(&self) -> Option<&(dyn Error + 'static)> {
            match self {
                ProductError::Sqlx(e) => Some(e),     // chain debug
                ProductError::NotFound(_) => None,
            }
        }
    }
    Service body chỉ cần ? operator: let row = repo::find_by_slug(pool, slug).await?;sqlx::Error auto convert sang ProductError::Sqlx qua impl From. Nếu thiếu #[from]: compiler báo lỗi ? operator không convert được, phải viết tay .map_err(ProductError::Sqlx)? mỗi call → boilerplate. Nếu thiếu #[error(transparent)]: caller log message kèm wrapping noise + source() chain bị mất, debugger không trace được nguyên nhân gốc. Best practice: luôn dùng cả 2 attribute khi wrap third-party error (sqlx::Error, stripe::StripeError, reqwest::Error, redis::RedisError). Khi nào KHÔNG dùng #[from]: variant cần custom logic conversion (vd parse sqlx::Error để extract constraint name); viết tay impl From riêng. Pattern lock Shop API: mọi domain error wrap third-party qua #[error(transparent)] + #[from]; variant business logic dùng #[error("custom msg {field}")].
  5. InvalidCredentials → 401 KHÔNG 404 — auth context security: Security threat: User enumeration attack. Attacker gửi nhiều POST /auth/login với email khác nhau + password sai cố tình. Nếu server map "email không tồn tại" → 404 NotFound + "password sai" → 401 Unauthorized → attacker phân biệt được email nào đã đăng ký dựa status code. Build database user qua brute-force enumerate. Sau đó dùng database email cho phishing, credential stuffing (test password leak từ data breach khác). OWASP Top 10 2021 A07 Identification and Authentication Failures: liệt kê user enumeration là risk cao. Pattern lock industry standard: trả 401 generic "invalid credentials" cho CẢ 2 case (email không tồn tại + password sai) — attacker không phân biệt được. Áp Shop API B73: UserError::InvalidCredentials → AppError::Unauthenticated (401) thay vì map theo internal logic. Body response generic: {"error": "invalid credentials", "code": "UNAUTHENTICATED"} KHÔNG có chi tiết "email exists" hay "password mismatch". Implementation pattern login bên trong service:
    // File: crates/shop-core/src/users.rs (impl PgUserService)
    async fn login(&self, email: &str, password: &str)
        -> Result<UserResponseDto, UserError>
    {
        let user = match repo::find_by_email(&self.pool, email).await? {
            Some(u) => u,
            None => return Err(UserError::InvalidCredentials),  // KHÔNG NotFound
        };
    
        if !verify_password(&user.password_hash, password)? {
            return Err(UserError::InvalidCredentials);             // generic
        }
    
        Ok(UserResponseDto::from(user))
    }
    Timing attack mitigation: nếu email không tồn tại trả nhanh + email tồn tại verify password chậm (Argon2id 100ms) → attacker phân biệt qua response time. Mitigation: chạy verify password với hash dummy nếu user không tồn tại để cân bằng timing (advanced, B104+ implement). Khác với resource NotFound: ProductError::NotFound → 404 OK vì product slug public, không leak thông tin user. Generalize: auth context không leak resource existence — lock pattern cho login, password reset, email verification request (POST /auth/forgot trả 204 dù email không tồn tại; chỉ gửi mail nếu user thật). Reference: NIST SP 800-63B Digital Identity Guidelines section 5.2.2; OWASP Authentication Cheat Sheet.
11

Bài Tiếp Theo

— explore #[derive(Crud)] proc macro pattern (advanced) tự generate 5 endpoint chuẩn từ struct definition; áp Shop API simple resource (Category, Brand) tránh boilerplate; trade-off macro complexity vs explicit handler — khi nào pattern macro thắng và khi nào nên giữ explicit handler.