Mục lục
- Mục Tiêu Bài Học
- 2 Anti-Pattern Error Trong Rust API
- thiserror Vs anyhow Trade-Off
- Refactor ProductError Pattern Standard
- impl From<ProductError> for AppError — Centralized Mapping
- 5 Domain Error Đầy Đủ
- Mapping Per Error → AppError
- Refactor Handler — Implicit ? Convert Domain → App
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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) vsanyhow-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 AppErrorcentralized trong 1 file boundary layer. - Hiểu
thiserrorvsanyhowtrade-off — khi nào chọn cái nào. - Service trait B72 giờ return
Result<DTO, DomainError>thayResult<DTO, AppError>— semantic rõ ràng per bounded context. - Handler convert
DomainError → AppErrorqua?operator auto — code KHÔNG đổi nhiều. - Hiểu orphan rule Rust — tại sao impl
Fromđặt ởshop-apithayshop-common. - Thêm
AppError::Forbidden(String)variant 20 (19 → 20) choEmailNotVerifiedscenario.
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 đề:
AppErrorlà HTTP-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-binary —
shop-worker(G15) +shop-cli(G16) gọi service nhưng KHÔNG cần HTTP envelope; kéo theoAppErrormang 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 sangDomainError::Sqlxqua#[from]. - Service trả
DomainError→ handler dùng?auto convert sangAppErrorquaimpl 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.
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):
thiserrorcho 5 domain error enum (ProductError+OrderError+PaymentError+CartError+UserError) — library code cần type-safe.anyhowchỉ ởcrates/shop-api/src/main.rsstartup logic — wire pool + config + log init không cần phân biệt error.AppErrorgiữ ở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.
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)]— forwardDisplay+source()sang inner error. Khi caller logProductError::Sqlx(err), message hiển thị giống hệtsqlx::Errorkhông thêm wrapping noise.#[from] sqlx::Error— generateimpl From<sqlx::Error> for ProductErrortự độ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.
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::error vì AppError ở đó. 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ả AppError và ProductError 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.
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:
- Infrastructure —
Sqlx,Stripe: wrap third-party error qua#[error(transparent)] + #[from]. Auto convert qua?operator. Mapping cuối cùng vềAppError::Internalhoặc deferred B55 logic. - Domain —
NotFound,Duplicate,Locked,Empty,InsufficientStock,InvalidTransition: business rule vi phạm. Caller (handler) map theo semantic — 404/409/422 tùy ngữ cảnh. - Authentication —
InvalidCredentials,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).
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 exposestripe::StripeErrormessage ra client. Stripe error có thể chứa internal account info, API key fragment, rate-limit detail; log đầy đủ quatracing::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
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
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.
Tổng Kết
- 2 anti-pattern:
AppError-everywhere (service couple HTTP) vsanyhow-everywhere (mất type information, không map status). - Pattern lock B73: 3 layer error —
sqlx::Error(repo) →DomainError(service) →AppError(handler/HTTP). thiserrorcho domain error (type-safe library code, 5 enum);anyhowchỉ ởmain.rstop-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-convertsqlx::Error → DomainError::Sqlxqua?operator.- Orphan rule Rust — impl
From<DomainError> for AppErrorđặt ởshop-api(boundary), KHÔNGshop-common(circular dep). crates/shop-api/src/error_map.rscentralized 5 implFrom— 1 nơi review variant mới.- Handler dùng
?operator — auto convertDomainError → 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::EmailNotVerifiedmap 403.- File path lock B73: extend 5 module trong
shop-core+ NEWcrates/shop-api/src/error_map.rs+ updatemain.rs+ extendshop-common::error::AppErrorthêmForbidden(String). - Service trait B72 refactor return type từ
Result<DTO, AppError>sangResult<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).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 2 anti-pattern
AppError-everywhere vsanyhow-everywhere — pros/cons mỗi cách? Solution layered là gì và tại sao tốt hơn cả 2? thiserrorvsanyhow— khi nào chọn mỗi crate? Cho ví dụ scenario library code vs binarymain.rs.- Orphan rule Rust — tại sao impl
From<ProductError> for AppErrorđặt ởshop-apithayshop-common? Mô tả scenario circular dep cụ thể. #[error(transparent)] + #[from]auto convert pattern — cho ví dụ vớisqlx::ErrorwrapProductError::Sqlx; điều gì xảy ra nếu thiếu#[from]?UserError::InvalidCredentials → 401 Unauthorized— tại sao KHÔNG map 404 NotFound? Giải thích auth context security pattern.
Đáp án
- 2 anti-pattern pros/cons + solution layered: AppError-everywhere (service return
AppErrortrực tiếp). Pros: (i) đơn giản, ít boilerplate; (ii) handler nhận thẳngAppErrorkhô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 importshop-common::error, assert variant HTTP-specific thay vì assert semantic; (c) không reuse cross-binary —shop-worker+shop-clikéo theo HTTP dependency không cần; (d) violate Single Responsibility — 1 enum gánh cả business + HTTP. anyhow-everywhere (service returnanyhow::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ảierr.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?+ implFrom. Tại sao tốt hơn cả 2: (i) type-safe —thiserrorenum compiler enforce match exhaustive; (ii) decoupled — service không biết HTTP, reuse cross-binary OK; (iii) centralized mapping — 1 fileerror_map.rskiể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. - 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ụ:
ProductErrortrongshop-core::productsđể handler matchNotFoundvsSlugAlreadyExistsmap status khác nhau. Cú pháp:#[derive(Error)] enum X { #[error("msg")] Variant(field) }. Generate implstd::error::Error+Display+ (nếu có#[from]) implFrom<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::Erroropaque wrapper bọcdyn std::error::Error + Send + Sync + 'static+ dynamic backtrace. Use case: binarymain()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:thiserrorcho 5 domain error +AppError(đã dùng từ B10);anyhowchỉ ởmain.rsstartup. Anti-pattern: dùnganyhow::Errortrong 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". - 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:AppErrorởshop-common,ProductErrorởshop-core. ImplFrom<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-apiquaFromtrait ởstd). Circular dep scenario nếu đặt ởshop-common:shop-commoncần importshop_core::products::ProductError→shop-commondependshop-core. Nhưng dep graph hiện tạishop-core → shop-common(shop-core dùngshop_common::dto,shop_common::error::AppError). Add ngược lại tạo cycleshop-common → shop-core → shop-common— Cargo báo lỗicyclic dependency between crates, build fail. Workaround thử: táchAppErrorra crate thứ 3 (vdshop-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.rs—shop-apinằm cao nhất dep graph (shop-api → shop-core → shop-db → shop-common), depend cả 2 type → orphan rule OK vìFromtrait ởstd. Tại sao boundary layer hợp lý: (a)shop-apilà layer biết HTTP — chỗ natural để map domain → HTTP; (b)shop-corekhông nên biếtAppError(reuse cross-binary); (c)shop-commonkhông nên biếtDomainError(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êmshop-mailcrate vớiMailError→ implFrom<MailError> for AppErrorđặt ởshop-api/error_map.rscùng nguyên tắc. #[error(transparent)] + #[from]auto convert pattern: 2 attribute riêng biệt kết hợp cho pattern wrap third-party error.#[error(transparent)]— forwardDisplay+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]— generateimpl From<Inner> for Outertự động quathiserrormacro expand. Ví dụ vớisqlx::Error:
Macro generate code tương đương:#[derive(Debug, Error)] pub enum ProductError { #[error(transparent)] Sqlx(#[from] sqlx::Error), #[error("product {0} not found")] NotFound(String), }
Service body chỉ cần// 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, } } }?operator:let row = repo::find_by_slug(pool, slug).await?;—sqlx::Errorauto convert sangProductError::Sqlxquaimpl 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 parsesqlx::Errorđể extract constraint name); viết tayimpl Fromriê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}")].- InvalidCredentials → 401 KHÔNG 404 — auth context security: Security threat: User enumeration attack. Attacker gửi nhiều POST
/auth/loginvớ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:
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:// 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)) }ProductError::NotFound → 404OK 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/forgottrả 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.
Bài Tiếp Theo
Bài 74: CRUD Macro Derive Pattern — 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.
