🧱 Dựng API với axum và cấu trúc project

By Everwise Intern

Bạn đã có đủ đồ nghề: ownership, lifetimes, traits, Result thay cho panic, và async/await với Tokio. Bài này ghép chúng lại thành một thứ chạy được thật: một HTTP service cho ngân hàng, có layer rõ ràng, có endpoint tài khoản và giao dịch.

Nguyên tắc tôi luôn nói với intern: handler mỏng, service dày, repository ngu. Viết sai chỗ này thì sáu tháng sau không ai dám sửa luồng tiền.

Ba layer và một AppState

Luồng một request đi qua đúng ba trạm:

flowchart TD
    Client["🌐<br/>HTTP client"]
    Handler["🧾<br/>handler"]
    Service["⚙️<br/>service"]
    Repo["🗄️<br/>repository"]
    Client --> Handler --> Service --> Repo
  • handler: parse request, gọi service, map kết quả thành response. Không chứa business rule, không biết tiền được tính thế nào.
  • service: toàn bộ quy tắc nghiệp vụ (kiểm tra số dư, trạng thái tài khoản). Không biết HTTP là gì.
  • repository: nơi duy nhất được nói chuyện với database. Không biết HTTP, không biết nghiệp vụ.

Cấu trúc project nên như sau, tách theo layer chứ không tách theo kiểu "models/", "utils/" chung chung:

bank-service/
  Cargo.toml
  src/
    main.rs
    app.rs
    error.rs
    domain/
      mod.rs
      account.rs
      money.rs
    handlers/
      mod.rs
      accounts.rs
      transfers.rs
    service/
      mod.rs
      account_service.rs
    repo/
      mod.rs
      in_memory.rs

AppState là cầu nối. Nó phải Clone vì axum clone state cho mỗi request, nên bên trong dùng Arc:

#[async_trait::async_trait]
pub trait AccountRepository: Send + Sync {
    async fn find(&self, id: AccountId) -> Result<Option<Account>, RepoError>;
    async fn save(&self, account: &Account) -> Result<(), RepoError>;
}

#[derive(Clone)]
pub struct AppState {
    pub accounts: Arc<AccountService>,
}

pub struct AccountService {
    repo: Arc<dyn AccountRepository>,
}

Tip: Ở bước này, hãy viết InMemoryAccountRepo trước. Bạn chạy được end-to-end trong 20 phút, và khi đổi sang Postgres ở bài sau bạn chỉ thay một dòng trong main.rs.

Luyện tay: handler sau vi phạm layer ở đâu?

async fn transfer(State(s): State<AppState>, Json(req): Json<TransferReq>) -> StatusCode {
    let from = s.db.query("SELECT * FROM accounts WHERE id = $1", &[&req.from]).await;
    if from.balance < req.amount { return StatusCode::BAD_REQUEST; }
    StatusCode::OK
}

Xem gợi ý

Ba lỗi: (1) handler gọi thẳng database, bỏ qua service và repository; (2) business rule kiểm tra số dư nằm trong handler; (3) trả StatusCode trơn, client không biết lỗi gì. Cách đúng: handler chỉ gọi state.accounts.transfer(req).await và map AppError sang response.

Spec API cho bản capstone đầu tiên

Giữ nhỏ và version hóa ngay từ đầu, vì bạn sẽ còn sửa:

Method Path Việc cần làm
GET /health Trả 200, không chạm database
POST /api/v1/accounts Tạo tài khoản
GET /api/v1/accounts/{id} Xem số dư, trạng thái
POST /api/v1/transfers Chuyển tiền giữa hai tài khoản

Router gom một chỗ, dễ đọc và dễ mở rộng:

pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/api/v1/accounts", post(handlers::accounts::create))
        .route("/api/v1/accounts/{id}", get(handlers::accounts::get_one))
        .route("/api/v1/transfers", post(handlers::transfers::create))
        .with_state(state)
}

Chú ý: từ axum 0.8, path param viết {id} chứ không còn :id. Copy code cũ trên mạng là lỗi route ngay lập tức.

Handler chỉ nên dài vài dòng:

async fn get_one(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<AccountResponse>, AppError> {
    let account = state.accounts.get(id).await?;
    Ok(Json(account.into()))
}

Lỗi thì gom vào một enum, implement IntoResponse một lần cho cả service:

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("không tìm thấy tài khoản")]
    NotFound,
    #[error("dữ liệu không hợp lệ")]
    Validation(String),
    #[error("lỗi nội bộ")]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = match &self {
            AppError::NotFound => StatusCode::NOT_FOUND,
            AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
            AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
        };
        if matches!(self, AppError::Internal(_)) {
            tracing::error!(error = ?self, "lỗi nội bộ");
        }
        (status, Json(json!({ "message": self.to_string() }))).into_response()
    }
}

Trong service ngân hàng: _____ chỉ parse request và map response, còn toàn bộ quy tắc nghiệp vụ nằm ở _____. State dùng chung được lấy trong handler qua extractor _____, và nó phải Clone nên bên trong dùng _____. Từ axum 0.8, path param khai báo bằng dấu _____ thay vì dấu hai chấm.

Luyện tay: viết handler create cho POST /api/v1/accounts, nhận Json<CreateAccountReq>, trả 201 Created kèm id.

Xem gợi ý

async fn create(
    State(state): State<AppState>,
    Json(req): Json<CreateAccountReq>,
) -> Result<(StatusCode, Json<AccountResponse>), AppError> {
    let account = state.accounts.create(req).await?;
    Ok((StatusCode::CREATED, Json(account.into())))
}

Điểm cần nhớ: (StatusCode, Json<T>) cũng implement IntoResponse, nên không cần tự dựng Response.

Thứ tự thi công

Đừng viết cả service rồi mới chạy thử. Đi theo thứ tự này, mỗi bước chạy được:

  1. cargo new bank-service, thêm axum, tokio (feature full), serde, thiserror, tracing, async-trait, uuid.
  2. Viết /health + main.rs bind 0.0.0.0:8080 bằng tokio::net::TcpListener. Chạy curl thấy 200 là xong bước 1.
  3. Dựng domain/ với Account, AccountId, Money. Tiền dùng i64 minor units hoặc Decimal, tuyệt đối không f64.
  4. Viết trait AccountRepository + impl in-memory, kèm vài unit test thuần Rust.
  5. Viết AccountService gọi repository, trả AppError.
  6. Nối AppState, viết handler create và get_one. Test bằng curl.
  7. Thêm POST /transfers nhưng chỉ validate hình dạng request. Tính nhất quán để bài sau.

Nghiệm thu

Bạn xong bài này khi:

  • cargo run lên, curl localhost:8080/health trả 200.
  • Tạo tài khoản rồi GET /api/v1/accounts/{id} trả đúng số dư.
  • Gọi GET với id không tồn tại trả 404 kèm JSON, không phải trang lỗi HTML hay panic.
  • Không có unwrap() nào trên đường đi của request; grep -rn "unwrap()" src/ chỉ ra kết quả trong test.
  • Handler không có một dòng SQL nào, repository không có một dòng StatusCode nào.
  • Mỗi file dưới 200 dòng. Vượt qua là dấu hiệu layer đang bị trộn.

Cạm bẫy khi dựng axum service cho tiền

Warning: Đừng để Internal trả nguyên nội dung lỗi ra client. Stack trace hay câu SQL là món quà cho kẻ tấn công, và ở ngân hàng đó là sự cố bảo mật, không phải bug nhỏ.

  • Dùng f64 cho tiền. Sai số làm lệch một đồng và bộ phận đối soát sẽ tìm bạn. Dùng i64 minor units hoặc Decimal.
  • Nhét business rule vào handler. Ban đầu chỉ một if, ba tháng sau là 200 dòng. Service phải là nơi duy nhất quyết định giao dịch hợp lệ hay không.
  • Trả StatusCode trơn. Client không phân biệt được "sai định dạng" với "không đủ tiền". Luôn trả JSON có mã lỗi ổn định.
  • unwrap() trên Path<Uuid>. Id sai định dạng làm panic cả task. Dùng extractor mặc định, nó tự trả 400.
  • Quên .with_state(state) sau các route. Lỗi compile khó đọc, nguyên nhân thường chỉ là thứ tự gọi.
  • Đặt route cụ thể sau route tham số. /accounts/me khai báo sau /accounts/{id} sẽ bị nuốt.

Chốt lại

Bạn vừa có bộ khung service ngân hàng chạy được: ba layer tách bạch, AppState bọc Arc, router có version, lỗi gom về một AppError duy nhất và map sang HTTP status đúng chỗ. Đây là nền mà mọi thứ phía sau sẽ dựa vào.

Bài tiếp theo đi vào phần khó nhất của luồng tiền: validation chặt hơn và giữ tính nhất quán khi một giao dịch chạm vào hai tài khoản cùng lúc.

Front: Handler trong kiến trúc 3 layer được phép làm gì? Back: Parse request, gọi service, map kết quả thành response; không chứa business rule.

Front: Layer nào là nơi duy nhất được nói chuyện với database? Back: Repository.

Front: Extractor nào trong axum dùng để lấy state dùng chung? Back: State, qua tham số State(state): State.

Front: Vì sao AppState phải Clone? Back: Axum clone state cho mỗi request, nên bên trong dùng Arc để clone rẻ.

Front: Từ axum 0.8, path param viết thế nào? Back: Dùng ngoặc nhọn: /accounts/{id}, thay cho /accounts/:id.

Front: Muốn map lỗi domain sang HTTP status ở một chỗ, implement trait nào? Back: IntoResponse cho enum lỗi của app (ví dụ AppError).

Front: Với lỗi nội bộ 500, client nên nhận gì? Back: Message chung, còn chi tiết chỉ ghi log qua tracing.

Front: Vì sao không dùng f64 cho số tiền? Back: Sai số dấu phẩy động; dùng i64 minor units hoặc Decimal.

Front: Vì sao repository nên là trait? Back: Service không phụ thuộc implementation, dễ thay in-memory bằng Postgres và dễ test.

Front: Handler trả về kiểu gì để vừa có nhánh lỗi vừa có nhánh thành công? Back: Result<Json, AppError> với AppError: IntoResponse.

Front: Route /health nên làm gì? Back: Trả 200 ngay, không chạm database, dùng cho load balancer.

Front: Vì sao version hóa API bằng /api/v1 ngay từ đầu? Back: Cho phép thay đổi breaking sau này mà không phá client cũ.

Front: Lợi ích của in-memory repository ở bước đầu? Back: Chạy được end-to-end trước khi có database, sau đó chỉ thay một impl khác.