Danh sách bài viết

Bài 15: JSON Serialization Với serde + axum::Json

Bài 15 của series Rust RESTful API — đi sâu vào serde (SerDe = Serialize + Deserialize) framework de/serialization chuẩn của Rust với 2 trait cốt lõi Serialize (Rust value → format) và Deserialize (format → Rust value), derive macro #[derive(Serialize, Deserialize)] tự sinh impl che 90% use case, field attribute thực tế rename per-field + rename_all cấp struct + skip_serializing_if bỏ field None giảm payload + default dùng Default::default() khi field missing + alias chấp nhận tên cũ cho backward compat, 4 mode tagged enum cho polymorphism (externally tagged default, internally tagged qua tag = "type", adjacently tagged qua tag + content, untagged auto-detect bằng shape) cùng JSON shape so sánh, vai trò kép của axum::Json<T> vừa extract request body (T: DeserializeOwned) vừa response (T: Serialize) với Content-Type tự set; lock convention DTO Shop API: #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] mọi DTO match frontend, #[serde(skip_serializing_if = "Option::is_none")] cho mọi Option<T> field response, #[serde(default)] cho field optional request, enum polymorphic dùng internally tagged #[serde(tag = "type", rename_all = "snake_case")], file path lock crates/shop-api/src/dto/<resource>.rs.

12/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 serde 2 trait cốt lõi: Serialize (Rust → JSON) và Deserialize (JSON → Rust), bản chất framework de/serialization tổng quát không lock format cụ thể.
  • Biết derive macro #[derive(Serialize, Deserialize)] setup nhanh — tự sinh impl 2 trait cho struct/enum, che 90% use case không cần custom code.
  • Áp dụng field attribute thực tế: rename per-field, rename_all cấp struct, skip_serializing_if bỏ field None khỏi output, default dùng default value khi field missing, alias chấp nhận tên cũ cho backward compat.
  • Hiểu tagged enum cho polymorphism với 4 mode JSON shape khác nhau: externally tagged (default), internally tagged (tag = "type"), adjacently tagged (tag + content), untagged (auto-detect bằng shape).
  • Biết axum::Json<T> vai trò kép — extract request body (T: DeserializeOwned) khi đặt làm argument handler, response (T: Serialize) khi return từ handler.
  • Lock convention DTO Shop API: rename_all = "camelCase" match frontend JS, skip_serializing_if cho response, default cho request optional, internally tagged cho enum polymorphic, file path crates/shop-api/src/dto/<resource>.rs.
2

serde Là Gì? Workflow

serde (đọc "ser-dee", viết tắt SerDe = Serialize + Deserialize) là framework de/serialization tổng quát cho Rust, ra đời 2014 bởi David Tolnay, hiện là crate cốt lõi của ecosystem Rust web/data với hàng nghìn crate downstream phụ thuộc. Triết lý thiết kế: tách data model (struct/enum trong Rust) khỏi data format (JSON, YAML, TOML, BSON, MessagePack, CBOR, ...) qua hai trait trung gian — bạn derive 2 trait một lần cho struct, sau đó chọn format adapter nào tùy use case.

Format được hỗ trợ qua crate adapter riêng — serde core không biết về JSON. Một số adapter phổ biến:

  • serde_json — JSON (RFC 8259), adapter dùng cho web API.
  • serde_yaml / serde_yaml_ng — YAML cho config file.
  • toml — TOML cho Cargo.toml và config Rust.
  • bson — BSON cho MongoDB driver.
  • rmp-serde — MessagePack binary compact.
  • ciborium — CBOR cho IoT/embedded.

Hai trait cốt lõi của serde:

// Trait Serialize — Rust value → format (qua Serializer)
pub trait Serialize {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer;
}

// Trait Deserialize<'de> — format → Rust value (qua Deserializer)
pub trait Deserialize<'de>: Sized {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>;
}

Lifetime 'de trên Deserialize cho phép zero-copy parse — field &'de str có thể tham chiếu thẳng vào input buffer thay vì allocate String mới. Trong context web API, bạn thường dùng DeserializeOwned (alias for<'de> Deserialize<'de>) để struct owned hoàn toàn — request body parse xong free buffer được ngay.

Derive macro #[derive(Serialize, Deserialize)] tự generate impl 2 trait cho struct/enum bạn khai báo — bạn không phải viết tay fn serialize + fn deserialize. Macro này che 90% use case; chỉ khi cần custom format logic (vd serialize DateTime thành Unix timestamp thay RFC 3339) bạn mới cần #[serde(serialize_with = "...", deserialize_with = "...")] attribute hoặc impl tay (deep dive B45).

Workspace dependencies đã lock từ B10 — không phải sửa Cargo.toml ở bài này:

# File: shop/Cargo.toml — [workspace.dependencies] section
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Feature derive bắt buộc — bật macro #[derive(Serialize, Deserialize)] qua sub-crate serde_derive. Crate serde_json là adapter: cung cấp serde_json::to_string(), serde_json::to_vec(), serde_json::from_str(), serde_json::from_slice(), macro serde_json::json! cho ad-hoc JSON value. axum dùng serde_json dưới hood cho Json<T> extractor + response.

3

Derive Cơ Bản Với Struct

Use case phổ biến nhất của serde là derive 2 trait cho struct DTO (Data Transfer Object) — đại diện shape JSON trao đổi với client. Code minh họa cho ProductDto mặc định chưa apply attribute:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct ProductDto {
    pub id: i64,
    pub name: String,
    pub price: rust_decimal::Decimal,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

JSON output mặc định khi serialize value:

{
  "id": 42,
  "name": "Demo Laptop",
  "price": "1499.00",
  "created_at": "2026-06-12T14:30:00Z"
}

Quan sát ba điểm:

  • Field name match snake_case Rust mặc định — serde không tự đổi case. Field created_at trong struct ra "created_at" trong JSON.
  • Decimal serialize as string — đã lock B6 JSON Format Policy cho money, tránh float precision loss. Crate rust_decimal cần feature serde-with-str (tên có thể khác theo version, kiểm tra docs) hoặc helper attribute để force string serialization.
  • DateTime serialize as RFC 3339 với suffix Z cho UTC — mặc định của chrono::DateTime<Utc> khi feature serde bật, align lock B6.

Issue lớn nhất với output mặc định: frontend JavaScript convention là camelCase — client expect "createdAt" chứ không phải "created_at". Cross-team contract phổ biến: backend Rust dùng snake_case nội bộ (idiomatic Rust + match PG column convention đã lock), wire format ra ngoài là camelCase (idiomatic JS). serde giải quyết đẹp gap này qua field attribute — không phải đổi tên struct.

4

Field Attribute: rename & rename_all

Per-field #[serde(rename = "...")] đổi tên một field cụ thể khi de/serialize, struct Rust giữ nguyên:

#[derive(Serialize, Deserialize)]
pub struct ProductDto {
    #[serde(rename = "id")]
    pub product_id: i64,         // Rust: product_id  →  JSON: "id"

    pub name: String,            // Rust: name        →  JSON: "name"
}

Use case per-field rename: legacy API có tên field "xấu" trong wire format mà bạn muốn refactor trong struct Rust, hoặc adapter cho external API contract đã fix không đổi được.

Struct-level #[serde(rename_all = "...")] đổi convention cho mọi field trong struct cùng lúc — không phải gắn attribute cho từng field:

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductDto {
    pub product_id: i64,                              // → "productId"
    pub display_name: String,                         // → "displayName"
    pub created_at: chrono::DateTime<chrono::Utc>,    // → "createdAt"
    pub is_active: bool,                              // → "isActive"
}

JSON output sau khi apply:

{
  "productId": 42,
  "displayName": "Demo Laptop",
  "createdAt": "2026-06-12T14:30:00Z",
  "isActive": true
}

Các option cho rename_all:

  • "camelCase"productId, displayName (frontend JS convention).
  • "PascalCase"ProductId, DisplayName (C#/.NET convention).
  • "snake_case"product_id, display_name (Rust/Python convention, default cho struct field Rust).
  • "SCREAMING_SNAKE_CASE"PRODUCT_ID, DISPLAY_NAME (constant convention).
  • "kebab-case"product-id, display-name (URL/CLI convention).
  • "SCREAMING-KEBAB-CASE"PRODUCT-ID (hiếm dùng).
  • "lowercase" / "UPPERCASE"productid / PRODUCTID (glue chữ, không phổ biến).

Lock Shop API: mọi DTO sử dụng #[serde(rename_all = "camelCase")] ở struct level — match frontend JS convention. Lý do quyết định: (1) frontend Shop API là SPA (React/Vue/Next) dùng JS/TS, JS naming convention chuẩn camelCase từ ECMAScript spec; (2) Rust internal naming giữ snake_case idiomatic — không "Việt hóa" code Rust theo frontend; (3) serde xử lý transparent ở boundary — cost zero runtime, compile-time macro generate đúng tên một lần. Per-field rename chỉ dùng cho exception (vd field cũ cần backward compat đã có client production gọi tên cũ).

5

skip_serializing_if, default, alias

Ba field attribute thực tế dùng nhiều nhất sau rename_all:

#[serde(skip_serializing_if = "Option::is_none")] — không serialize field nếu callable trả true. Phổ biến nhất là bỏ field None khỏi JSON output để giảm payload và tránh client confuse giữa "field tồn tại với null" vs "field missing":

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductDto {
    pub id: i64,
    pub name: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub discount_percent: Option<u8>,
}

Khi value là ProductDto { id: 42, name: "X", description: None, discount_percent: Some(15) }, output:

{
  "id": 42,
  "name": "X",
  "discountPercent": 15
}

Field description không xuất hiện vì None. Nếu bạn KHÔNG apply attribute, output sẽ là "description": null — explicit null trong JSON. Hai shape khác semantic: null nói "field tồn tại, giá trị là null"; missing nói "field không có". PATCH partial update sẽ exploit khác biệt này (lock B6, deep dive B66).

#[serde(default)] — dùng Default::default() nếu field missing trong input khi deserialize. Hữu ích cho optional field trong request DTO:

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProductDto {
    pub name: String,
    pub price: rust_decimal::Decimal,

    #[serde(default)]                  // missing → false (default cho bool)
    pub is_active: bool,

    #[serde(default)]                  // missing → empty Vec
    pub tags: Vec<String>,
}

Khi client gửi body { "name": "X", "price": "10.00" }, deserialize thành CreateProductDto { name: "X", price: 10.00, is_active: false, tags: vec![] } — hai field missing dùng default. Không có #[serde(default)] mà field missing → deserialize fail với error "missing field 'isActive'".

Biến thể: #[serde(default = "fn_name")] dùng function custom thay Default::default():

fn default_page_size() -> u32 { 20 }

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductFilter {
    pub page: u32,

    #[serde(default = "default_page_size")]
    pub page_size: u32,                // missing → 20 (sensible default)
}

#[serde(alias = "...")] — chấp nhận tên cũ trong deserialize cho backward compat. Khi đổi tên field trong struct, alias cho phép client cũ gọi tên cũ vẫn parse được:

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProductDto {
    #[serde(alias = "title", alias = "product_name")]
    pub name: String,
}

Cả ba shape input đều parse vào field name: { "name": "X" } (chuẩn), { "title": "X" } (legacy v1), { "product_name": "X" } (legacy v2). Lưu ý: alias chỉ áp dụng chiều deserialize; chiều serialize vẫn dùng tên chính sau rename_all.

Shop API decision: (1) #[serde(skip_serializing_if = "Option::is_none")] cho mọi Option<T> field trong response DTO — giảm payload, tránh confusion client. (2) #[serde(default)] cho field optional trong request DTO — tolerant input, không bắt client gửi field redundant. (3) #[serde(alias = "...")] chỉ dùng khi rename field đã có client production gọi tên cũ — tránh breaking change. PATCH partial update dùng pattern Option<Option<T>> qua serde_with::rust::double_option (lock B6), không phải skip_serializing_if đơn lẻ.

6

Tagged Enum Cho Polymorphism

Use case polymorphism trong API: response/request có nhiều variant khác nhau cùng dùng chung một key (vd Notification có thể là Email với to address, hoặc Push với device_id, hoặc Sms với phone_number). serde hỗ trợ 4 mode tag cho enum:

Mode 1 — Externally tagged (default) khi không khai báo gì:

#[derive(Serialize, Deserialize)]
pub enum Notification {
    Email { to: String, subject: String },
    Push { device_id: String, badge: u32 },
}

JSON shape:

{
  "Email": {
    "to": "[email protected]",
    "subject": "Order confirmed"
  }
}

Variant name làm key bao ngoài, payload nằm trong value. Đơn giản nhưng JSON shape không "phẳng" — client phải đọc thêm một level để biết type.

Mode 2 — Internally tagged qua #[serde(tag = "type")]:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Notification {
    Email { to: String, subject: String },
    Push { device_id: String, badge: u32 },
}

JSON shape:

{
  "type": "email",
  "to": "[email protected]",
  "subject": "Order confirmed"
}

Field type nằm flat cùng cấp với payload — RESTful style, dễ parse client. Constraint: variant phải là struct variant (named field) hoặc unit variant; không apply được cho tuple variant kiểu Email(String). rename_all = "snake_case" đổi Email"email" trong tag value.

Mode 3 — Adjacently tagged qua #[serde(tag = "type", content = "data")]:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum Notification {
    Email { to: String, subject: String },
    Push { device_id: String, badge: u32 },
}

JSON shape:

{
  "type": "email",
  "data": {
    "to": "[email protected]",
    "subject": "Order confirmed"
  }
}

Tag và payload tách biệt thành 2 field cấp ngoài. Hỗ trợ mọi loại variant (struct/tuple/unit). Trade-off: thêm một level nesting cho data, client phải đọc hai bước.

Mode 4 — Untagged qua #[serde(untagged)]:

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
pub enum Notification {
    Email { to: String, subject: String },
    Push { device_id: String, badge: u32 },
}

JSON shape:

{ "to": "[email protected]", "subject": "Order confirmed" }

Không có tag field — serde detect variant lúc deserialize bằng shape (field nào tồn tại). Trade-off lớn: detect ambiguous khi 2 variant cùng shape (vd cùng có to), serde thử lần lượt và pick variant đầu tiên match được — dễ bug khó debug. Use case hợp lý: variant shape rõ ràng khác nhau hoàn toàn, hoặc hợp với external API contract đã fix dạng untagged.

Shop API decision: dùng internally tagged #[serde(tag = "type", rename_all = "snake_case")] cho mọi enum response/request polymorphic. Lý do: (1) RESTful convention — client đọc field type ở cấp ngoài để discriminate; (2) JSON shape phẳng, dễ parse với JSON Schema và OpenAPI tooling (utoipa hỗ trợ); (3) rename_all = "snake_case" ở enum level đổi variant name EmailNotification"email_notification" consistent với REST naming convention. Field name bên trong variant vẫn theo rename_all = "camelCase" ở struct level (cần khai báo riêng cho từng struct variant nếu nested).

7

axum::Json<T> Trong Context

Wrapper axum::Json<T> đã giới thiệu ở B14 với vai trò response — bài này confirm vai trò kép trong full context handler:

  • Request body extract: Json(dto): Json<CreateProductDto> đặt làm argument cuối handler. Trait bound T: DeserializeOwned (owned hoàn toàn để free buffer body sau parse). axum đọc body bytes, gọi serde_json::from_slice::<T> parse, fail → reject 422 Unprocessable Entity với generic message.
  • Response body: Json(product): Json<ProductDto> trong return. Trait bound T: Serialize. axum gọi serde_json::to_vec::<T> serialize, set Content-Type application/json; charset=utf-8 tự động, body là bytes JSON.

Handler đầy đủ minh họa pattern Shop API kết hợp Json extract + tuple response (lock B13 + B14):

// File: crates/shop-api/src/handlers/products.rs (preview, sẽ thật ở G7 B62)
use axum::{
    extract::State,
    http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
    Json,
};
use shop_common::error::AppResult;

use crate::dto::product::{CreateProductDto, ProductDto};

async fn create_product(
    State(state): State<AppState>,
    Json(dto): Json<CreateProductDto>,                          // body extract cuối
) -> AppResult<(StatusCode, HeaderMap, Json<ProductDto>)> {
    let product = state.product_service.create(dto).await?;

    let mut headers = HeaderMap::new();
    let location = format!("/api/v1/products/{}", product.id);
    headers.insert(LOCATION, HeaderValue::from_str(&location)?);

    Ok((StatusCode::CREATED, headers, Json(product)))
}

Quan sát: Json<CreateProductDto> extractor đặt cuối arg list (rule lock B13 — body extractor một lần), Json<ProductDto> trong tuple response giữ thứ tự cố định (StatusCode, HeaderMap, body) (lock B14). serde derive trên cả hai DTO che hoàn toàn parse/serialize logic — handler chỉ business code.

Error case khi extract fail: axum dùng JsonRejection trả 422 với message generic dạng "Failed to deserialize the JSON body into the target type". Cho dev đủ debug nhưng cho production thì leak thông tin internal (tên field, type expected) — không acceptable cho public API. Shop API sẽ wrap custom error qua extractor wrapper riêng để map về AppError::Validation với envelope chuẩn { error, code, request_id } đã lock B3 — chi tiết ở B41 (JSON Extract + Validation Với validator Crate) khi tích hợp validator crate.

8

Lock Shop API DTO Convention

Tổng hợp quyết định lock cho mọi DTO Shop API — apply từ B41 onward khi handler thật bắt đầu implement:

  • File path lock: crates/shop-api/src/dto/<resource>.rs — mỗi resource một file (vd product.rs, order.rs, user.rs, cart.rs). Module dto aggregate qua crates/shop-api/src/dto/mod.rs với pub mod product; pub mod order; ....
  • Derive standard: #[derive(Debug, Clone, Serialize, Deserialize)]Debug cho log/tracing, Clone cho test fixture + pass qua boundary, hai trait serde cho de/serialize. Thêm ToSchema (utoipa) khi đến B8 OpenAPI integration.
  • Convention casing: #[serde(rename_all = "camelCase")] ở struct level — match frontend convention.
  • Option response field: #[serde(skip_serializing_if = "Option::is_none")] cho mọi Option<T> trong response DTO — giảm payload, tránh confusion null vs missing.
  • Option request optional: #[serde(default)] cho field client có thể bỏ qua — tolerant input.
  • Enum polymorphic: #[serde(tag = "type", rename_all = "snake_case")] — internally tagged, variant name lowercase snake_case.
  • DateTime: chrono::DateTime<chrono::Utc> serialize ra RFC 3339 với suffix Z (default serde format, lock B6).
  • Money: rust_decimal::Decimal serialize as string để tránh float precision loss (lock B6).
  • ID: i64 default serialize as number — realistic case dưới 2^53 JS safe integer (lock B6).

Code snippet đầy đủ ProductDto chuẩn theo lock — template cho mọi resource DTO sau:

// File: crates/shop-api/src/dto/product.rs
use serde::{Deserialize, Serialize};
use rust_decimal::Decimal;
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductDto {
    pub id: i64,
    pub name: String,
    pub slug: String,
    pub price: Decimal,                                       // serialize as string

    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub discount_percent: Option<u8>,

    pub is_active: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProductDto {
    pub name: String,
    pub slug: String,
    pub price: Decimal,

    #[serde(default)]
    pub description: Option<String>,

    #[serde(default)]
    pub is_active: bool,                                      // missing → false
}

JSON shape của ProductDto sau serialize (giả sử description = None, discount_percent = Some(15)):

{
  "id": 42,
  "name": "Demo Laptop",
  "slug": "demo-laptop",
  "price": "1499.00",
  "discountPercent": 15,
  "isActive": true,
  "createdAt": "2026-06-12T14:30:00Z",
  "updatedAt": "2026-06-12T14:30:00Z"
}

Field description không xuất hiện vì None + apply skip_serializing_if. Toàn bộ field naming camelCase frontend-friendly. Money price là string, datetime ISO 8601 UTC.

Lock này áp dụng mọi DTO resource trong Shop API tương lai — không sáng tạo convention riêng cho từng module. Khi tạo DTO mới (B41 onward), copy template, sửa field theo entity tương ứng. Sub-agent đọc _plans/shop-state.md section "DTO Convention" để confirm trước khi viết handler.

9

Tổng Kết

  • serde = framework de/serialization tổng quát Rust với 2 trait cốt lõi Serialize (Rust → format) và Deserialize (format → Rust); derive macro #[derive(Serialize, Deserialize)] tự generate impl che 90% use case.
  • serde_json là adapter cho JSON, axum dùng serde_json dưới hood cho Json<T> extractor + response; format adapter khác cũng có (yaml, toml, bson, msgpack, cbor).
  • Field attribute thực tế: rename per-field, rename_all cấp struct (camelCase/PascalCase/snake_case/SCREAMING_SNAKE_CASE/kebab-case), skip_serializing_if bỏ field None khỏi output, default dùng default value khi missing, alias cho backward compat.
  • Tagged enum 4 mode: externally tagged (default, variant name làm key bao ngoài), internally tagged (tag = "type" flat cùng cấp), adjacently tagged (tag + content tách 2 field), untagged (auto-detect bằng shape).
  • axum::Json<T> vai trò kép: extract request body khi T: DeserializeOwned đặt cuối arg list, response khi T: Serialize trong return, Content-Type tự set application/json; charset=utf-8.
  • Lock Shop API DTO convention:
    • Mọi DTO: #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")].
    • #[serde(skip_serializing_if = "Option::is_none")] cho mọi Option<T> response.
    • #[serde(default)] cho Option<T> request optional.
    • Enum polymorphic: #[serde(tag = "type", rename_all = "snake_case")] internally tagged.
    • DateTime chrono::DateTime<chrono::Utc>, money rust_decimal::Decimal as string, ID i64 as number.
  • File path lock: crates/shop-api/src/dto/<resource>.rs (vd product.rs, order.rs, user.rs).
10

Bài Tập Củng Cố

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

  1. serde derive 2 trait gì? Khi nào cần Serialize, khi nào cần Deserialize? Có thể derive một trong hai mà không cần derive cả hai không?
  2. Shop API DTO dùng #[serde(rename_all = "?")] nào? Tại sao chọn camelCase mà không snake_case? Convention nội bộ Rust có bị "Việt hóa" theo frontend không?
  3. 4 mode tagged enum khác nhau JSON shape thế nào với cùng một variant Email { to: String }? Shop API chọn mode nào? Lý do?
  4. #[serde(skip_serializing_if = "Option::is_none")] để làm gì? Khi nào KHÔNG nên dùng?
  5. Handler POST nhận Json<CreateProductDto>. T = CreateProductDto cần implement trait nào? Compiler check ở đâu, lúc compile hay runtime?
Đáp án
  1. serde derive 2 trait Serialize (Rust → format) và Deserialize (format → Rust). Khi nào cần từng cái: (a) Serialize cần khi struct ra wire — response DTO trả ra client, log structured ghi xuống file, message gửi qua queue/broker. (b) Deserialize cần khi struct nhận từ wire — request DTO parse body từ client, config load từ TOML/YAML, message nhận từ queue/broker. (c) Đa số DTO cần cả hai vì same struct dùng cả hai chiều (cùng ProductDto response trả client lẫn input cho mock test) — nên derive cả hai mặc định. Có thể derive một trong hai khi cần ít hơn: response DTO chỉ ra (vd error envelope server build tay) — chỉ Serialize; request DTO chỉ vào (vd CreateProductDto tạo từ client, server không bao giờ trả ra) — chỉ Deserialize. Shop API thực tế: response DTO derive cả hai (cho symmetric debug log + test deserialize lại), request DTO chỉ Deserialize (tiết kiệm code gen, không bao giờ serialize lại). Lock B15 đề nghị cả hai trên mọi DTO cho consistency, evaluate optimize sau nếu compile time là vấn đề.
  2. Shop API DTO dùng #[serde(rename_all = "camelCase")]. Lý do chọn camelCase mà không snake_case: (a) frontend Shop API là SPA (React/Vue/Next) dùng JS/TS, JS naming convention chuẩn camelCase từ ECMAScript spec — convention cross-team default; (b) ESLint/Prettier auto-format theo camelCase, contributor frontend không phải tay sửa snake_case; (c) backend Rust internal naming giữ snake_case idiomatic (struct field, function, variable, PG column theo lock database convention) — KHÔNG "Việt hóa" code Rust theo frontend; (d) serde xử lý transparent ở boundary — cost zero runtime, compile-time macro generate code chuyển case một lần. Đây là split convention: snake_case nội bộ codebase Rust + PG, camelCase wire format ra ngoài, gap được serde đóng. Lý do KHÔNG snake_case wire format: nhiều JS library/framework expect camelCase, nếu trả snake_case client phải tự transform (manual hoặc qua library như humps), thêm complexity không cần thiết khi serde đã giải quyết.
  3. 4 mode tagged enum khác JSON shape với variant Email { to: String }:
    • Externally tagged (default): { "Email": { "to": "[email protected]" } } — variant name làm key bao ngoài, payload trong value.
    • Internally tagged #[serde(tag = "type")]: { "type": "Email", "to": "[email protected]" } — tag flat cùng cấp với payload field.
    • Adjacently tagged #[serde(tag = "type", content = "data")]: { "type": "Email", "data": { "to": "[email protected]" } } — tag và payload tách 2 field.
    • Untagged #[serde(untagged)]: { "to": "[email protected]" } — không có tag, serde detect bằng shape.
    Shop API chọn internally tagged #[serde(tag = "type", rename_all = "snake_case")]. Lý do: (a) RESTful convention — client đọc field "type" ở cấp ngoài để discriminate variant trước khi đọc payload, pattern phổ biến trong Stripe API, GitHub Events API; (b) JSON shape phẳng, dễ parse cho JSON Schema + OpenAPI tooling utoipa (đã lock B8); (c) rename_all = "snake_case" đổi variant name EmailNotification"email_notification" consistent với REST naming snake_case convention cho identifier; (d) Externally tagged shape không phẳng phải đọc 2 level — không hợp REST; adjacently tagged thêm nesting data không cần thiết khi internally đã đủ; untagged ambiguous detect khi 2 variant cùng shape gây bug khó debug.
  4. #[serde(skip_serializing_if = "Option::is_none")] để bỏ field khỏi JSON output khi callable (Option::is_none) trả true. Cụ thể: field Option<T> với value None sẽ KHÔNG xuất hiện trong JSON, thay vì serialize ra "field": null. Mục đích: (a) giảm payload size (production khi list 1000 record với 5 optional field None có thể tiết kiệm 30-50% bytes); (b) phân biệt 2 semantic JSON khác nhau — "field": null nói "field tồn tại, giá trị là null"; field missing nói "field không có". PATCH partial update exploit khác biệt này. Khi nào KHÔNG nên dùng: (a) Schema bắt buộc field tồn tại với null — vd OpenAPI/JSON Schema spec yêu cầu strict mọi field response phải present, client code-gen từ schema sẽ fail nếu field missing; (b) API contract đã document client phải có field — bỏ field đột ngột làm client cũ crash null pointer khi access; (c) Tooling downstream parse strict field exist — vd ETL job đọc JSON expect field discount_percent luôn có, missing field bị skip không tính sai. Shop API mặc định apply skip_serializing_if cho response DTO vì frontend SPA của Shop API tolerant missing field (check qua obj?.field optional chaining), client integration thứ ba được document rõ "missing field = null"; nếu sau này expose API public cho external partner phải bật explicit null cho contract rõ ràng, evaluate per endpoint.
  5. T = CreateProductDto cần implement trait serde::de::DeserializeOwned (alias của for<'de> Deserialize<'de> — deserialize không borrow gì từ input). Lý do DeserializeOwned chứ không phải Deserialize<'de> đơn lẻ: body bytes được axum đọc từ socket vào buffer rồi gọi serde_json::from_slice — sau parse xong buffer được drop, DTO phải own toàn bộ data nội tại không borrow vào buffer. Deserialize<'de> cho phép zero-copy borrow vào input buffer; DeserializeOwned bắt buộc owned. Khi bạn derive #[derive(Deserialize)] cho struct với field owned (String, i64, Vec<T>, Option<T>, ...), trait DeserializeOwned tự được implement — bạn không phải khai báo gì thêm. Cụ thể trong axum, trait bound check ở FromRequest<S> impl cho Json<T>: impl<T: DeserializeOwned, S> FromRequest<S> for Json<T>. Compiler check ở đâu: lúc compile, không phải runtime. Khi bạn viết handler async fn create_product(Json(dto): Json<CreateProductDto>), axum macro infer trait bound cho handler signature qua Handler<T, S> trait — nếu CreateProductDto không implement DeserializeOwned (vd quên derive Deserialize), cargo build báo error "the trait DeserializeOwned is not implemented for CreateProductDto" với suggested fix là derive Deserialize. Đây là compile-time safety của axum — không bao giờ ship code production với DTO sai missing derive.
11

Bài Tiếp Theo

— chi tiết implement AppError: IntoResponse mapping 11 variants → HTTP status đúng (lock B3), error envelope JSON { error, code, request_id }, file crates/shop-api/src/responses/error.rs (lock từ B14). Mọi handler sẽ return AppResult<Json<T>> clean từ B16 onward, error path tự động map về status code + body chuẩn không phải mỗi handler tự handle.