Mục lục
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ế:
renameper-field,rename_allcấp struct,skip_serializing_ifbỏ fieldNonekhỏi output,defaultdùng default value khi field missing,aliaschấ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_ifcho response,defaultcho request optional, internally tagged cho enum polymorphic, file pathcrates/shop-api/src/dto/<resource>.rs.
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.
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_attrong struct ra"created_at"trong JSON. - Decimal serialize as string — đã lock B6 JSON Format Policy cho money, tránh float precision loss. Crate
rust_decimalcần featureserde-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
Zcho UTC — mặc định củachrono::DateTime<Utc>khi featureserdebậ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.
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ũ).
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ẻ.
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).
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 boundT: DeserializeOwned(owned hoàn toàn để free buffer body sau parse). axum đọc body bytes, gọiserde_json::from_slice::<T>parse, fail → reject 422 Unprocessable Entity với generic message. - Response body:
Json(product): Json<ProductDto>trong return. Trait boundT: Serialize. axum gọiserde_json::to_vec::<T>serialize, set Content-Typeapplication/json; charset=utf-8tự độ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.
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 (vdproduct.rs,order.rs,user.rs,cart.rs). Moduledtoaggregate quacrates/shop-api/src/dto/mod.rsvớipub mod product; pub mod order; .... - Derive standard:
#[derive(Debug, Clone, Serialize, Deserialize)]—Debugcho log/tracing,Clonecho test fixture + pass qua boundary, hai trait serde cho de/serialize. ThêmToSchema(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ọiOption<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 suffixZ(default serde format, lock B6). - Money:
rust_decimal::Decimalserialize as string để tránh float precision loss (lock B6). - ID:
i64default serialize as number — realistic case dưới2^53JS 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.
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_jsonlà adapter cho JSON, axum dùngserde_jsondưới hood choJson<T>extractor + response; format adapter khác cũng có (yaml, toml, bson, msgpack, cbor).- Field attribute thực tế:
renameper-field,rename_allcấp struct (camelCase/PascalCase/snake_case/SCREAMING_SNAKE_CASE/kebab-case),skip_serializing_ifbỏ fieldNonekhỏi output,defaultdùng default value khi missing,aliascho 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 + contenttách 2 field), untagged (auto-detect bằng shape). axum::Json<T>vai trò kép: extract request body khiT: DeserializeOwnedđặt cuối arg list, response khiT: Serializetrong return, Content-Type tự setapplication/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ọiOption<T>response.#[serde(default)]choOption<T>request optional.- Enum polymorphic:
#[serde(tag = "type", rename_all = "snake_case")]internally tagged. - DateTime
chrono::DateTime<chrono::Utc>, moneyrust_decimal::Decimalas string, IDi64as number.
- Mọi DTO:
- File path lock:
crates/shop-api/src/dto/<resource>.rs(vdproduct.rs,order.rs,user.rs).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- serde derive 2 trait gì? Khi nào cần
Serialize, khi nào cầnDeserialize? Có thể derive một trong hai mà không cần derive cả hai không? - 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? - 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? #[serde(skip_serializing_if = "Option::is_none")]để làm gì? Khi nào KHÔNG nên dùng?- Handler POST nhận
Json<CreateProductDto>.T = CreateProductDtocần implement trait nào? Compiler check ở đâu, lúc compile hay runtime?
Đáp án
- serde derive 2 trait
Serialize(Rust → format) vàDeserialize(format → Rust). Khi nào cần từng cái: (a)Serializecần khi struct ra wire — response DTO trả ra client, log structured ghi xuống file, message gửi qua queue/broker. (b)Deserializecầ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ùngProductDtoresponse 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 (vdCreateProductDtotạ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 đề. - 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. - 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.
#[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 nameEmailNotification→"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 nestingdatakhông cần thiết khi internally đã đủ; untagged ambiguous detect khi 2 variant cùng shape gây bug khó debug. - Externally tagged (default):
#[serde(skip_serializing_if = "Option::is_none")]để bỏ field khỏi JSON output khi callable (Option::is_none) trảtrue. Cụ thể: fieldOption<T>với valueNonesẽ 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 fieldNonecó thể tiết kiệm 30-50% bytes); (b) phân biệt 2 semantic JSON khác nhau —"field": nullnó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 fielddiscount_percentluôn có, missing field bị skip không tính sai. Shop API mặc định applyskip_serializing_ifcho response DTO vì frontend SPA của Shop API tolerant missing field (check quaobj?.fieldoptional 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.T = CreateProductDtocần implement traitserde::de::DeserializeOwned(alias củafor<'de> Deserialize<'de>— deserialize không borrow gì từ input). Lý doDeserializeOwnedchứ không phảiDeserialize<'de>đơn lẻ: body bytes được axum đọc từ socket vào buffer rồi gọiserde_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;DeserializeOwnedbắt buộc owned. Khi bạn derive#[derive(Deserialize)]cho struct với field owned (String,i64,Vec<T>,Option<T>, ...), traitDeserializeOwnedtự được implement — bạn không phải khai báo gì thêm. Cụ thể trong axum, trait bound check ởFromRequest<S>impl choJson<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 handlerasync fn create_product(Json(dto): Json<CreateProductDto>), axum macro infer trait bound cho handler signature quaHandler<T, S>trait — nếuCreateProductDtokhông implementDeserializeOwned(vd quên deriveDeserialize),cargo buildbáo error "the trait DeserializeOwned is not implemented for CreateProductDto" với suggested fix là deriveDeserialize. Đây là compile-time safety của axum — không bao giờ ship code production với DTO sai missing derive.
Bài Tiếp Theo
Bài 16: Error Response Pattern Với IntoResponse — 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.
