# 🧱 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:

```mermaid
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:

```tree
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`:

```rust
#[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?

```rust
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:

```rust
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:

```rust
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:

```rust
#[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 ý**

```rust
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<AppState>.

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<T>, 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.

---

## Contents

Part of [🦀 Rust core cho fintech: nắm ownership để làm codebase tài chính](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh.md)

- **🦀 Rust core cho fintech**
  - [🧠 Ownership và borrowing khi xử lý tiền](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/ownership-borrowing-xu-ly-tien.md)
  - [🔗 Lifetimes và domain model tài chính](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/lifetimes-domain-model-tai-chinh.md) (sign-in required)
  - [🧩 Traits và generics để trừu tượng hóa](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/traits-generics-truu-tuong-hoa.md)
  - [✅ Checkpoint: Rust core cho fintech](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/checkpoint-rust-core-fintech.md)
- **🛡️ Error handling và concurrency an toàn**
  - [🚨 Result thay cho panic trong luồng tiền](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/result-khong-panic-luong-tien.md)
  - [⚡ Async/await, Tokio và chia sẻ state an toàn](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/async-tokio-chia-se-state-an-toan.md)
  - [✅ Checkpoint: Error handling và concurrency](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/checkpoint-error-handling-concurrency.md)
- **🏦 Capstone: service ngân hàng bằng Rust**
  - [🧱 Dựng API với axum và cấu trúc project](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/dung-api-axum-cau-truc-project.md) — this lesson
  - [💸 Xử lý giao dịch: validation và tính nhất quán](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/xu-ly-giao-dich-validation-nhat-quan.md) (premium content)
  - [🗄️ Kết nối database, test và quan sát lỗi](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/ket-noi-database-test-quan-sat-loi.md)
  - [✅ Checkpoint: Capstone service ngân hàng](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/checkpoint-capstone-service-ngan-hang.md)
- [Course cheatsheet](https://everwise.study/courses/rust-core-cho-fintech-nam-ownership-de-lam-codebase-tai-chinh/cheatsheet.md) (premium content)
