Mục lục
- Mục Tiêu Bài Học
- Vấn Đề: Primitive Bị Lẫn Lộn
- Newtype Pattern — Wrap Primitive
#[serde(transparent)]— Wire Format PhẳngMoney(Decimal)— Money Type Safety#[serde(flatten)]— Merge Field Con Vào Parent- Extra HashMap Pattern — Field Mở Rộng
- Apply Vào DTO Shop API: Refactor CreateProductDto
- 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 Newtype pattern — wrap primitive trong struct tuple đơn field để có type safety mà KHÔNG có runtime overhead (zero-cost abstraction).
- Phân biệt
UserId(u64)vàProductId(u64)— compiler ngăn truyền nhầm thứ tự argument mặc dù 2 type cùng layoutu64. - Derive
#[serde(transparent)]để JSON wire không lộ wrapper — client thấy raw primitive (123) thay{"0": 123}default. - Sử dụng
#[serde(flatten)]merge field con vào parent — DRY choPaginationreuse cross list endpoint. - Áp dụng pattern
HashMap<String, Value>extra cho metadata flexible (webhook payload, audit log, custom attributes). - Hiểu pitfall Map ordering trong serde —
HashMapKHÔNG ordered gây test snapshot fail;BTreeMapordered alphabetical lock cho metadata cần stable. - Áp
Money(Decimal)wraprust_decimal::Decimalcho Shop API thayu64/f64— precision chính xác + JSON string format tránh JS lose precision.
Vấn Đề: Primitive Bị Lẫn Lộn
Handler service layer Shop API điển hình nhận nhiều ID cùng kiểu u64. Signature đơn giản nhưng nguy hiểm:
// Anti-pattern — primitive lẫn lộn
pub async fn create_order(
user_id: u64,
product_id: u64,
quantity: u32,
) -> AppResult<OrderDto> {
// ...
}
Caller layer cao hơn (handler HTTP, route checkout) phải nhớ chính xác thứ tự argument:
// Đúng:
create_order(claims.sub, product.id, 1).await?;
// Sai — swap user_id và product_id:
create_order(product.id, claims.sub, 1).await?;
// ↑ compiler KHÔNG báo lỗi vì cả 2 đều u64
Bug runtime tệ hơn nhiều bug compile-time:
- Order tạo cho user sai — DB INSERT với
user_id = 99(giá trị thật làproduct_id) trong khi product field nhận giá trịuser_idkhông tồn tại; foreign key có thể fail (nếuproductstable không có row id 99), hoặc tệ hơn pass nếu có row tồn tại — tạo order phantom giữa 2 user thật. - Data corruption nhân lên nếu service downstream (cache, analytics) reuse misorder id — sửa khó vì root cause cách xa data hiện trạng.
Pattern thực tế: Stripe/PayPal/Square SDK cho money cũng có bug tương tự khi caller trộn amount_cents và amount_dollars — cùng kiểu u64/i64 không phân biệt được scale → tính sai 100 lần. Money đặc biệt nguy hiểm vì tổn thất tài chính thật.
Compiler Rust có khả năng catch lớp lỗi này nếu type system hỗ trợ — chỉ cần wrap primitive trong type tách biệt mỗi mục đích. Đây là động cơ của Newtype pattern.
Newtype Pattern — Wrap Primitive
Newtype là pattern Rust dùng struct tuple đơn field để wrap 1 type sẵn có thành type mới, phân biệt ở compile time. Định nghĩa UserId và ProductId:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProductId(pub u64);
Handler đổi signature dùng newtype:
pub async fn create_order(
user_id: UserId,
product_id: ProductId,
quantity: u32,
) -> AppResult<OrderDto> {
// ...
}
Caller bây giờ phải tạo explicit:
// Đúng:
create_order(UserId(claims.sub), ProductId(product.id), 1).await?;
// Sai — swap argument:
create_order(ProductId(product.id), UserId(claims.sub), 1).await?;
// ↑ compile error:
// mismatched types: expected `UserId`, found `ProductId`
Compiler bắt lỗi ngay tại điểm gọi — bug không bao giờ chạy đến production. Lệch type giữa 2 newtype mặc dù cùng layout u64 là điểm cốt lõi: type identity tách rời memory representation.
Convert giữa newtype và primitive là hiển ngôn qua field access:
// Wrap primitive thành newtype:
let user_id = UserId(claims.sub);
// Unwrap newtype lấy primitive (.0 truy cập field tuple):
let raw_id: u64 = user_id.0;
// Hoặc destructure:
let UserId(raw_id) = user_id;
Zero-cost abstraction: Rust compile struct đơn field xuống cùng layout với type wrap — UserId và u64 chiếm cùng 8 byte memory, copy/pass cùng tốc độ, không có runtime overhead. Type safety thuần ở compile time, runtime KHÔNG biết wrapper tồn tại.
So sánh với type alias:
// Type alias — KHÔNG có type safety:
type UserIdAlias = u64;
type ProductIdAlias = u64;
fn bad(user_id: UserIdAlias, product_id: ProductIdAlias) {}
bad(42_u64, 99_u64); // OK — alias chỉ là synonym
bad(99_u64, 42_u64); // VẪN OK — không compiler check
Type alias là synonym — compiler treat 2 alias giống nhau như u64 thuần. Newtype mới sinh ra type identity riêng — đây là khác biệt cốt lõi cho type safety.
Trait cần implement cho newtype hoạt động đầy đủ trong codebase: Display (format khi log/print), From<u64> + From<UserId> for u64 (convert giữa 2 chiều), FromStr (parse từ string, vd Path extractor). Chi tiết B58 sẽ implement đầy đủ cho 5 newtype Shop API.
#[serde(transparent)] — Wire Format Phẳng
Newtype derive Serialize + Deserialize mặc định wrap field index làm key JSON. Định nghĩa không attribute:
#[derive(Serialize, Deserialize)]
pub struct UserId(pub u64);
JSON wire format mặc định:
{"0": 123}
Key "0" là index field trong struct tuple — KHÔNG mong muốn vì client thấy key vô nghĩa, không phản ánh ý định "user id là số 123". Wrapper struct lộ ra wire format thay vì giữ trong suốt.
Solution: attribute #[serde(transparent)] báo serde "treat newtype như primitive bên trong":
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId(pub u64);
JSON wire format mới — chỉ raw value, không wrapper:
123
Bảng so sánh wire format có và không transparent:
// Pattern | Rust type | JSON output
// struct UserId(u64) | UserId | {"0": 123}
// #[serde(transparent)] above | UserId | 123
Attribute transparent chỉ dùng được trên struct đơn field (tuple variant với 1 field hoặc struct với 1 named field) — bản chất serde "skip wrapper" và delegate serialize cho field bên trong; nhiều field không có chỗ để skip.
Lock pattern Shop API: mọi newtype ID dùng #[serde(transparent)] MANDATORY — wire format giống primitive nguyên gốc, JSON client KHÔNG biết server-side dùng wrapper. Refactor primitive → newtype không break client.
Use case khác của transparent: wrap struct chứa lifetime/generic phức tạp nhưng wire format đơn giản — vd pub struct Email(pub String) giữ wire "[email protected]" string thuần thay {"0": "[email protected]"}; pub struct Token<'a>(pub &'a str) giữ wire "abc123".
Money(Decimal) — Money Type Safety
Tiền KHÔNG bao giờ nên đại diện bởi u64 hoặc f64. Hai pitfall:
f64mất chính xác — binary float IEEE 754 không lưu chính xác số thập phân base 10. Ví dụ kinh điển:0.1 + 0.2 == 0.30000000000000004(kết quả thật) thay0.3mong muốn. Phép cộng giá tiền liên tục cộng dồn sai số → báo cáo financial sai lệch sau triệu giao dịch.u64không có scale — số1000có nghĩa1000 VNDhay10 USD(1000 cent) hay0.01 BTC(1000 satoshi)? Không có chỗ lưu scale trong type, dev phải nhớ convention từng field — vi phạm Project Spec scale lock.
Solution: Decimal từ crate rust_decimal — số thập phân base 10 chính xác 28-29 chữ số signed, lưu mantissa + scale + sign trong 128 bit. Phép cộng/trừ/nhân/chia bảo toàn chính xác miễn không vượt quá 28 chữ số có ý nghĩa.
Định nghĩa Money wrap Decimal:
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Money(pub Decimal);
Workspace deps thêm rust_decimal với feature serde-with-str:
# Cargo.toml workspace root
[workspace.dependencies]
rust_decimal = { version = "1", features = ["serde-with-str"] }
Feature serde-with-str kích hoạt impl Serialize/Deserialize cho Decimal dưới dạng JSON string thay JSON number — Money(Decimal::new(2500000, 2)) serialize thành "25000.00" (string) thay 25000.00 (number).
Wire format JSON với serde-with-str:
{
"price": "25000.00",
"discount": "1500.50"
}
Lý do bắt buộc string thay number cho money:
- JavaScript lose precision với JSON number lớn hơn
2^53— JSNumberlà IEEE 754 double, mất chính xác từ số thứ 16 chữ số. Giá VND 9 chữ số (100_000_000) vẫn an toàn nhưng tổng order/cart cộng dồn có thể vượt threshold trong tương lai (B2B order lớn). - Decimal exact preserve qua wire — string
"25000.00"chuyển y nguyên, parse phía server lại thànhDecimal::from_str("25000.00")đúng giá trị ban đầu. - Industry standard — Stripe API trả
amount_decimal: "1000"string, Shopify API trảprice: "10.99"string, PayPal API trảvalue: "10.99"string. Mọi payment gateway lớn dùng string cho money.
Code đầy đủ module mới crates/shop-common/src/dto/types.rs chứa 5 newtype Shop API:
// File: crates/shop-common/src/dto/types.rs
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Newtype wrap user ID — phân biệt với ProductId/OrderId/CategoryId compile time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId(pub u64);
/// Newtype wrap product ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProductId(pub u64);
/// Newtype wrap order ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct OrderId(pub u64);
/// Newtype wrap category ID.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CategoryId(pub u64);
/// Newtype wrap Decimal cho tiền — JSON string format qua feature serde-with-str
/// để tránh JS lose precision với JSON number lớn hơn 2^53.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Money(pub Decimal);
impl Money {
/// Hằng số 0 dùng cho default value, comparison.
pub const ZERO: Self = Self(Decimal::ZERO);
/// Constructor wrap `Decimal::new(amount, scale)`.
/// - `amount`: mantissa số nguyên signed 64-bit (vd 2_500_000)
/// - `scale`: số chữ số sau dấu phẩy (vd 2 cho 25000.00)
pub fn new(amount: i64, scale: u32) -> Self {
Self(Decimal::new(amount, scale))
}
}
Update crates/shop-common/src/dto/mod.rs thêm submodule types + re-export 5 newtype top-level:
// File: crates/shop-common/src/dto/mod.rs (snippet — chỉ phần B44 thêm)
pub mod payment;
pub mod product;
pub mod types;
pub use payment::PaymentMethod;
pub use product::{CreateProductDto, SLUG_REGEX, UpdateProductDto};
pub use types::{CategoryId, Money, OrderId, ProductId, UserId};
Cross-crate import 1 dòng use shop_common::dto::{UserId, ProductId, Money}; — không cần biết submodule path types.
Note: chi tiết Decimal precision/rounding (banker's rounding, half-up, scale conversion) sẽ ở B54 (Decimal in JSON). Bài này chỉ define wrapper + lock decision dùng Decimal.
#[serde(flatten)] — Merge Field Con Vào Parent
Use case: tách struct riêng để reuse cross endpoint nhưng JSON wire muốn phẳng. Pagination chứa page + per_page dùng cho mọi list endpoint:
#[derive(Serialize, Deserialize)]
pub struct Pagination {
pub page: u32,
pub per_page: u32,
}
#[derive(Serialize, Deserialize)]
pub struct ProductListQuery {
#[serde(flatten)]
pub pagination: Pagination,
pub search: Option<String>,
pub category: Option<String>,
}
JSON wire format phẳng — field của Pagination nằm cùng level với search và category:
{
"page": 1,
"per_page": 20,
"search": "iphone",
"category": "electronics"
}
KHÔNG phải lồng {"pagination": {...}, "search": "...", "category": "..."} như khi không có #[serde(flatten)]. Client gửi query string ?page=1&per_page=20&search=iphone parse thẳng qua Query<ProductListQuery>.
Code Rust gọn — truy cập query.pagination.page thay phải copy page/per_page field vào mọi list DTO:
pub async fn list_products(
Query(query): Query<ProductListQuery>,
) -> AppResult<ListResponse<ProductDto>> {
let page = query.pagination.page;
let per_page = query.pagination.per_page;
let search = query.search;
// ...
}
Pros của #[serde(flatten)]:
- DRY — define
Pagination1 lần, reuse cho mọi list endpoint (products, orders, users, categories, reviews) qua#[serde(flatten)] pagination: Pagination. - Wire format phẳng client friendly — không cần nested object, query string parse trực tiếp, OpenAPI schema gen flat.
Cons và pitfall cần biết:
- Order field không stable — serde
flattendùngMaptrung gian (typicallyBTreeMaphoặc internal struct) để collect field từ child struct rồi merge với parent; thứ tự field trong JSON output KHÔNG đảm bảo bằng thứ tự định nghĩa struct Rust. Quan trọng cho test snapshot — assert string JSON cố định sẽ fail khi serde thay đổi order. - Performance kém hơn không-flatten — serde phải build Map trung gian rồi merge, thay copy field trực tiếp như struct flat. Overhead ~10-20% với JSON deserialize body lớn (hàng chục field nested).
- Conflict field name runtime ERROR — nếu
Paginationcó field tên trùng fieldProductListQuery(vd cả 2 cósearch), serde deserialize sẽ ERROR runtime "duplicate field". Compile pass nhưng test fail. Refactor đổi tên dễ break cross struct.
Pattern Shop API: dùng #[serde(flatten)] cho query DTO (Pagination reuse list endpoint) — query string ngắn không sensitive performance. KHÔNG dùng cho domain entity (Product, Order, User) — ordering quan trọng cho audit trail + performance critical với entity nested deep.
Extra HashMap Pattern — Field Mở Rộng
Use case: API cần lưu metadata flexible (forward-compat) mà không định nghĩa cứng struct — webhook payload từ third-party, audit log record action data, custom attributes user-defined. Pattern #[serde(flatten)] extra: HashMap<String, Value> collect mọi field không match struct vào map:
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize)]
pub struct EventDto {
pub id: u64,
pub event_type: String,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
Client gửi JSON với field bất kỳ ngoài id và event_type — serde collect vào extra:
{
"id": 1,
"event_type": "click",
"user_agent": "Mozilla/5.0 ...",
"referer": "https://google.com",
"custom_field": 42
}
Sau deserialize: event.id = 1, event.event_type = "click", event.extra = {"user_agent": "Mozilla/5.0 ...", "referer": "https://google.com", "custom_field": 42}.
Pitfall Map ordering: HashMap KHÔNG ordered — internal hash table với randomized seed (Rust mặc định SipHash seeded với HashState random mỗi process); iterate HashMap trả field theo thứ tự khác nhau mỗi lần serialize. Wire JSON output field order RANDOM mỗi request:
// Lần serialize 1:
{"id": 1, "event_type": "click", "user_agent": "...", "referer": "..."}
// Lần serialize 2:
{"id": 1, "event_type": "click", "referer": "...", "user_agent": "..."}
Impact:
- Test snapshot fail — assert string JSON exact match sẽ fail khi order field khác.
- HTTP cache miss — proxy/CDN hash response body làm cache key; body khác = cache miss dù semantic giống.
- Audit log không reproducible — replay log không match bytes original.
Solution: dùng BTreeMap<String, Value> ordered alphabetical:
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize)]
pub struct EventDto {
pub id: u64,
pub event_type: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
BTreeMap dùng B-tree với key comparison Ord trait — iterate trả field theo thứ tự sort alphabetical, stable mọi lần serialize. Wire output deterministic:
// Mọi lần serialize cùng output (sort alphabetical):
{"id": 1, "event_type": "click", "referer": "...", "user_agent": "..."}
Lock pattern Shop API: BTreeMap<String, Value> MANDATORY cho extra metadata cần stable output — test snapshot, audit log, HTTP cache, replay log. HashMap<String, Value> chỉ dùng khi order không quan trọng (cache lookup nội bộ, rate limit counter — không serialize ra wire).
Use case extra HashMap/BTreeMap trong Shop API:
- Webhook payload — Stripe gửi event mới với field không lường trước trong tương lai → tránh hard-code mọi field, collect vào
extrađể log đầy đủ. - Audit log — admin action có metadata khác nhau theo loại action; lưu
BTreeMapJSON column ổn định cho diff. - Product custom attributes — admin thêm attribute riêng cho category (warranty months, voltage, color variant) không có trong schema sẵn.
Apply Vào DTO Shop API: Refactor CreateProductDto
B41 đã define CreateProductDto với price: u64. B44 refactor sang Money + thêm metadata: BTreeMap cho extension. Pattern lock Shop API: ID always newtype, Money always Decimal-backed, metadata always BTreeMap.
Update crates/shop-common/src/dto/product.rs:
// File: crates/shop-common/src/dto/product.rs
use std::collections::BTreeMap;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use validator::{Validate, ValidationError};
use crate::dto::Money;
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct CreateProductDto {
#[validate(length(min = 3, max = 200, message = "tên sản phẩm dài 3-200 ký tự"))]
pub name: String,
#[validate(regex(
path = *super::SLUG_REGEX,
message = "slug chỉ chứa chữ thường, số và dấu gạch ngang"
))]
pub slug: String,
/// Money(Decimal) thay u64 — type safety + precision chính xác.
/// Validate qua custom function vì validator built-in không hỗ trợ
/// Decimal range check (chỉ hỗ trợ primitive numeric).
#[validate(custom(function = "validate_money_positive"))]
pub price: Money,
#[validate(range(min = 0, max = 1_000_000, message = "stock phải từ 0 đến 1.000.000"))]
pub stock: u32,
/// Metadata flexible — BTreeMap đảm bảo order stable cho test snapshot
/// và audit log; mặc định empty nếu client KHÔNG gửi.
#[serde(default)]
pub metadata: BTreeMap<String, Value>,
}
/// Custom validator: kiểm tra Money > 0.
/// Signature MANDATORY `fn(&T) -> Result<(), ValidationError>` cho
/// attribute `#[validate(custom(function = "..."))]`.
fn validate_money_positive(money: &Money) -> Result<(), ValidationError> {
if money.0 <= Decimal::ZERO {
return Err(ValidationError::new("must_be_positive"));
}
Ok(())
}
Chi tiết lock B44:
price: Moneythayu64— type safety không trộn lẫn vớiquantity: u32,stock: u32; precision Decimal chính xác cho phép tiết kiệm sau decimal khi cần (vd2500000.50VND lẻ).#[validate(custom(function = "validate_money_positive"))]thayrange(min = 1, ...)vìMoneykhông phải primitive numeric — validator built-in không implHasLen/HasMin/HasMaxchoMoney. Custom function signature MANDATORYfn(&T) -> Result<(), ValidationError>.metadata: BTreeMap<String, Value>với#[serde(default)]— empty map nếu client KHÔNG gửi field;BTreeMapstable order cho test snapshot Shop API.- KHÔNG dùng
#[serde(flatten)]cho metadata trongCreateProductDto— domain entity nested, ordering critical, performance impact. Giữ metadata trong nested key riêng"metadata": {...}rõ ràng.
JSON body client gửi:
{
"name": "iPhone 15",
"slug": "iphone-15",
"price": "25000000.00",
"stock": 10,
"metadata": {
"category": "electronics",
"warranty_months": 12,
"brand": "Apple"
}
}
Wire format "25000000.00" string (feature serde-with-str) — server parse qua Decimal::from_str chính xác. Client TypeScript có thể tạo qua "25000000.00" literal hoặc decimal.js library; mobile Swift dùng NSDecimalNumber.
Handler service layer dùng newtype:
// Preview G7 B62 ProductService
pub async fn create_product(
State(state): State<AppState>,
ValidatedJson(dto): ValidatedJson<CreateProductDto>,
) -> AppResult<Created<ProductDto>> {
let product_id: ProductId = product_service
.create(dto.name, dto.slug, dto.price, dto.stock, dto.metadata)
.await?;
let response = ProductDto {
id: product_id, // ProductId không phải u64
price: dto.price, // Money không phải u64
// ...
};
Ok(Created {
location: format!("/api/v1/products/{}", response.slug),
data: response,
})
}
Wire format pros:
- Compile-time compiler ngăn truyền nhầm
ProductIdchỗUserIdservice layer. - Wire-time client thấy raw value (
123,"25000000.00") không lộ wrapper. - Future-proof thêm method instance trên
Money(fn round_vnd(&self) -> Self) hoặcUserId(fn is_admin(&self) -> bool) không break wire.
Verify compile module mới B44:
cd shop && cargo build -p shop-common
# Output:
# Compiling rust_decimal v1.x
# Compiling shop-common v0.1.0
# Finished `dev` profile [unoptimized + debuginfo] target(s)
Suggested commit:
git add crates/shop-common/src/dto/types.rs \
crates/shop-common/src/dto/mod.rs \
crates/shop-common/src/dto/product.rs \
Cargo.toml
git commit -m "B44: Newtype pattern + Money(Decimal) + dto/types.rs module + refactor CreateProductDto"
Tổng Kết
- Newtype pattern: wrap primitive cho type safety — compiler ngăn truyền nhầm
UserIdchỗProductIdmặc dù cùng layoutu64. - Zero-cost abstraction — Rust compile struct đơn field xuống cùng layout với type wrap, KHÔNG runtime overhead.
#[serde(transparent)]lock cho mọi newtype Shop API — wire format phẳng như primitive, client KHÔNG thấy wrapper struct.Money(Decimal)thayu64/f64cho tiền —rust_decimalprecision chính xác 28-29 chữ số signed, phép cộng/trừ/nhân/chia bảo toàn.- JSON string format cho
Moneyqua featureserde-with-strtránh JS lose precision với JSON number lớn hơn2^53("25000.00"thay25000.00). #[serde(flatten)]: merge field con vào parent — DRY choPaginationreuse cross list endpoint, wire format phẳng query string.- Pitfall flatten: order field không stable (serde dùng Map trung gian), performance kém hơn không-flatten ~10-20%, conflict field name runtime ERROR khi 2 struct cùng key.
HashMapvsBTreeMap:BTreeMaplock cho metadata cần ordered output (test snapshot, audit log, HTTP cache);HashMapchỉ khi order không quan trọng.- 5 Newtype ID lock B44:
UserId,ProductId,OrderId,CategoryId,Money— đầy đủ deriveDebug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize+#[serde(transparent)]. - File path lock B44:
crates/shop-common/src/dto/types.rschứa 5 newtype cross-domain primitive wrapper; refactorprice: u64→MoneytrongCreateProductDto; thêmmetadata: BTreeMap<String, Value>+validate_money_positivecustom validator function. - Custom validator function với
#[validate(custom(function = "..."))]lock pattern khi rule phức tạp không match field attribute built-in. - Foundation cho B45 (Serialize skip None + rename + multi-format), B54 (Decimal precision deep dive — rounding, scale conversion), B58 (FromStr/Display impl cho ID newtype parse Path extractor), B62 (schema price NUMERIC type DB).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Newtype pattern khác type alias (
type UserId = u64) thế nào? Type alias có type safety không? Cho ví dụ code 2 hàm với 2 cách, gọi swap argument xem compiler báo gì. #[serde(transparent)]thay đổi gì về JSON output? So sánh wire format có và không attribute chopub struct Email(pub String)với value"[email protected]".- Tại sao Money dùng
Decimalthayf64hoặcu64? Cho ví dụ pitfall mỗi loại — cụ thể phép tính nào fail. #[serde(flatten)]có 3 pitfall nào? Liệt kê và giải thích impact mỗi pitfall trong test snapshot, performance, refactor.- Khi nào dùng
BTreeMapthayHashMapcho extra field? Use case test snapshot — viết test assert string JSON với cả 2 loại, giải thích cái nào pass cái nào fail flaky.
Đáp án
- Newtype pattern khác type alias — type safety + ví dụ code. Type alias
type UserId = u64; type ProductId = u64;— chỉ là synonym, compiler treat 2 alias giống nhau nhưu64thuần. Không có type identity riêng. Newtypepub struct UserId(pub u64); pub struct ProductId(pub u64);— sinh 2 type identity tách rời, compiler phân biệt mặc dù cùng layoutu64. Type alias KHÔNG có type safety:
Newtype có type safety:type UserIdAlias = u64; type ProductIdAlias = u64; fn bad(user_id: UserIdAlias, product_id: ProductIdAlias) {} bad(42_u64, 99_u64); // OK bad(99_u64, 42_u64); // VẪN OK — swap mà không báo lỗi let u: UserIdAlias = 42; let p: ProductIdAlias = u; // OK — gán cross alias không lỗi
Trade-off: newtype verbose hơn (phải wrap/unwrap quapub struct UserId(pub u64); pub struct ProductId(pub u64); fn good(user_id: UserId, product_id: ProductId) {} good(UserId(42), ProductId(99)); // OK good(ProductId(99), UserId(42)); // ❌ compile error: // mismatched types: expected `UserId`, found `ProductId` let u: UserId = UserId(42); let p: ProductId = u; // ❌ compile error — gán cross newtype lỗi.0field access hoặc destructure), nhưng đổi lại catch lớp lỗi swap argument compile-time. Industry pattern: ID/Money/Email/Url MANDATORY newtype; counter/index/temporary number primitive thuần OK. Lock Shop API B44: mọi domain ID (UserId, ProductId, OrderId, CategoryId) + Money MANDATORY newtype — KHÔNG type alias. #[serde(transparent)]thay đổi JSON output — wire format có/không attribute. Không attribute (mặc định) — serde wrap field index làm key:
Key#[derive(Serialize, Deserialize)] pub struct Email(pub String); let email = Email("[email protected]".to_string()); serde_json::to_string(&email).unwrap(); // Output: {"0": "[email protected]"}"0"là field index struct tuple (Rust đánh số field tuple từ 0). Client thấy key vô nghĩa, không phản ánh "đây là email value". Có#[serde(transparent)]:
Wire format giống#[derive(Serialize, Deserialize)] #[serde(transparent)] pub struct Email(pub String); let email = Email("[email protected]".to_string()); serde_json::to_string(&email).unwrap(); // Output: "[email protected]"Stringnguyên gốc — wrapper struct trong suốt. Attributetransparentbáo serde "skip wrapper" và delegate serialize/deserialize trực tiếp cho field bên trong. Constraint: chỉ dùng được trên struct đơn field (tuple với 1 field hoặc struct với 1 named field) — bản chất là skip wrapper, nhiều field không có chỗ skip. Use case Shop API: mọi newtype ID (UserId(123)→ JSON123), Money (Money(Decimal::new(2500000, 2))→ JSON"25000.00"string qua serde-with-str), Email (Email("[email protected]")→ JSON"[email protected]"). Refactor primitive → newtype không break client vì wire identical. Lock B44:#[serde(transparent)]MANDATORY mọi newtype Shop API.- Money dùng Decimal thay f64/u64 — pitfall mỗi loại. Pitfall f64 (binary float IEEE 754): không lưu chính xác số thập phân base 10. Phép tính kinh điển:
Phép cộng giá tiền liên tục (cart totals, order summary, monthly aggregate) cộng dồn sai số → báo cáo financial sai lệch sau triệu giao dịch. Banker dùng decimal arithmetic cho lý do này — IEEE 754 KHÔNG đủ chính xác cho money. Pitfall u64 (integer không scale): sốlet a: f64 = 0.1; let b: f64 = 0.2; let sum: f64 = a + b; println!("{}", sum); // Output: 0.30000000000000004 assert_eq!(sum, 0.3); // ❌ fail — sai số binary float1000ambiguous semantic. Ví dụ:
Service B treat// Service A trả price cents: let price_cents: u64 = 1000; // = 10.00 USD // Service B nhận price VND raw: fn create_order(price: u64) { /* expect 1000 = 1000 VND */ } create_order(price_cents); // Bug: 1000 cent → 1000 VND (sai 100×)1000như VND nhưng service A gửi cent — sai 100 lần. Không có scale trong type, phải nhớ convention từng field. Solution Decimal: lưu mantissa + scale + sign trong 128 bit.Decimal::new(2_500_000, 2)=25000.00(mantissa 2_500_000, scale 2). Phép cộng:
Chính xác 28-29 chữ số signed, đủ cho mọi giao dịch tài chính thực tế (trillion dollar precision sub-cent). Lock Shop API B44:use rust_decimal::Decimal; use rust_decimal_macros::dec; let a = dec!(0.1); let b = dec!(0.2); let sum = a + b; assert_eq!(sum, dec!(0.3)); // ✅ pass — Decimal chính xácMoney(Decimal)MANDATORY, KHÔNG bao giờf64hoặcu64cho tiền. Featureserde-with-strMANDATORY — JSON string format"25000.00"tránh JS lose precision với JSON number lớn hơn2^53. #[serde(flatten)]3 pitfall + impact. (a) Order field không stable: serde flatten dùngMaptrung gian (typicallyBTreeMaphoặc internal struct) để collect field từ child rồi merge với parent; thứ tự field JSON output KHÔNG đảm bảo bằng thứ tự định nghĩa struct Rust. Impact test snapshot: assert string JSON exact match (vdassert_eq!(json_string, "{\"page\":1,\"per_page\":20,\"search\":\"x\"}")) sẽ fail flaky khi serde thay đổi order field giữa version. Phải dùngassert_json_eq!(compare semantic) thay assert string. (b) Performance kém hơn không-flatten ~10-20%: serde phải build Map trung gian rồi merge, thay copy field trực tiếp như struct flat. Impact: API throughput giảm với body lớn (hàng chục field nested). Profilecargo benchShop API show overhead rõ ở P99 latency request body 50KB+. (c) Conflict field name runtime ERROR: nếuPaginationcó fieldsearchtrùngProductListQuery.search, serde deserialize sẽ ERROR runtime "duplicate field `search`". Impact refactor: compile pass nhưng test fail runtime — bug ẩn cho đến endpoint chạy production. Refactor đổi tên dễ break cross struct vì compiler KHÔNG báo trước. Strategy: dùng#[serde(flatten)]cho query DTO (Pagination reuse list endpoint — query string ngắn không sensitive performance + test ít hơn entity); KHÔNG dùng cho domain entity (Product, Order, User) — ordering quan trọng cho audit + performance critical với entity nested deep + nhiều test snapshot.- BTreeMap vs HashMap cho extra field — use case test snapshot. HashMap pitfall: internal hash table với randomized seed (Rust mặc định
SipHashseeded vớiHashStaterandom mỗi process); iterateHashMaptrả field theo thứ tự khác nhau mỗi lần serialize. Wire JSON output field order RANDOM. BTreeMap stable: B-tree với key comparisonOrdtrait — iterate trả field theo thứ tự sort alphabetical, stable mọi lần serialize. Test snapshot — viết test cả 2:
Use case Shop API: BTreeMap MANDATORY cho test snapshot (assert wire format exact bytes), audit log (replay log match bytes original cho compliance investigation), HTTP cache (proxy/CDN hash response body làm cache key — body khác bytes = cache miss dù semantic giống → performance regression), webhook signature verify (Stripe/GitHub sign body bytes — order field thay đổi break signature). HashMap chỉ dùng: cache lookup nội bộ (không serialize ra wire), rate limit counter (in-memory state), de-duplication set (semantic không cần order). Lock Shop API B44: extra metadata serialize ra wire format MANDATORYuse std::collections::{BTreeMap, HashMap}; use serde_json::json; // HashMap — flaky test #[derive(Serialize)] struct EventHash { id: u64, #[serde(flatten)] extra: HashMap<String, String>, } #[test] fn test_hashmap_snapshot() { let mut extra = HashMap::new(); extra.insert("user_agent".to_string(), "Mozilla".to_string()); extra.insert("referer".to_string(), "google.com".to_string()); let event = EventHash { id: 1, extra }; let json = serde_json::to_string(&event).unwrap(); // ❌ FLAKY — có thể fail: assert_eq!(json, r#"{"id":1,"user_agent":"Mozilla","referer":"google.com"}"#); // Lần chạy khác có thể trả: // {"id":1,"referer":"google.com","user_agent":"Mozilla"} } // BTreeMap — stable test #[derive(Serialize)] struct EventBTree { id: u64, #[serde(flatten)] extra: BTreeMap<String, String>, } #[test] fn test_btreemap_snapshot() { let mut extra = BTreeMap::new(); extra.insert("user_agent".to_string(), "Mozilla".to_string()); extra.insert("referer".to_string(), "google.com".to_string()); let event = EventBTree { id: 1, extra }; let json = serde_json::to_string(&event).unwrap(); // ✅ STABLE — luôn pass (sort alphabetical: referer trước user_agent): assert_eq!(json, r#"{"id":1,"referer":"google.com","user_agent":"Mozilla"}"#); }BTreeMap<String, Value>;CreateProductDto.metadata, futureEventDto.extrawebhook payload,AuditLog.metadataadmin action data đều dùngBTreeMap.
Bài Tiếp Theo
Bài 45: JSON Skip + Rename + Multi-Format — #[serde(skip)] ẩn field internal khỏi wire format, #[serde(skip_serializing_if = "Option::is_none")] tránh null verbose response, #[serde(rename = "...")] map tên field non-Rust-idiomatic (camelCase, kebab-case), rename_all cho toàn struct cùng convention, áp dụng cho UserResponseDto Shop API ẩn password_hash + internal_notes khỏi wire.
