Danh sách bài viết

Bài 44: Newtype Pattern + serde flatten

Bài 44 của series Rust RESTful API — đi sâu Newtype pattern wrap primitive trong struct tuple đơn field (pub struct UserId(pub u64)) để có type safety zero-cost: compiler ngăn truyền nhầm UserId chỗ ProductId vì là 2 type khác biệt mặc dù cùng layout u64; phân biệt với type alias (type UserId = u64) — alias chỉ là synonym KHÔNG có type safety, compiler treat như u64 truyền lẫn lộn vô tư; derive #[serde(transparent)] để JSON wire format giữ raw primitive (123) thay default wrap field index ({"0": 123}) — lock cho mọi ID newtype Shop API; Money(Decimal) wrap rust_decimal::Decimal thay u64 hoặc f64 cho tiền — f64 binary float mất chính xác (0.1 + 0.2 ≠ 0.3), u64 không có scale (1000 = 1000 VND hay 10 USD = 1000 cent?); workspace deps thêm rust_decimal = { version = "1", features = ["serde-with-str"] } để JSON serialize dưới dạng string ("25000.00") tránh JavaScript lose precision với JSON number lớn hơn 2^53; #[serde(flatten)] merge field con vào parent — Pagination { page, per_page } nested trong ProductListQuery với #[serde(flatten)] pagination: Pagination sinh wire phẳng {"page": 1, "per_page": 20, "search": "iphone"} thay lồng {"pagination": {...}}; pros DRY reuse Pagination cho mọi list endpoint + wire format phẳng client friendly; cons pitfall ordering field không stable (serde flatten dùng Map trung gian không đảm bảo order định nghĩa), performance kém hơn không-flatten vì Map → struct overhead, conflict field name runtime ERROR nếu 2 struct cùng field; Extra HashMap pattern #[serde(flatten)] extra: HashMap<String, Value> cho metadata flexible forward-compat (webhook payload, audit log, custom attributes) — pitfall HashMap KHÔNG ordered → wire JSON field order random mỗi lần serialize gây test snapshot fail; solution BTreeMap<String, Value> ordered alphabetical — lock cho metadata cần stable order; apply vào Shop API tạo module mới crates/shop-common/src/dto/types.rs chứa 5 newtype: UserId(pub u64), ProductId(pub u64), OrderId(pub u64), CategoryId(pub u64), Money(pub Decimal) đầy đủ derive Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize + #[serde(transparent)] MANDATORY; Money kèm const ZERO và constructor fn new(amount: i64, scale: u32) -> Self wrap Decimal::new; update crates/shop-common/src/dto/mod.rs thêm pub mod types; + pub use types::{UserId, ProductId, OrderId, CategoryId, Money}; re-export top-level; refactor crates/shop-common/src/dto/product.rs đổi field price: u64 sang price: Money + thêm metadata: BTreeMap<String, serde_json::Value> với #[serde(default)] + custom validator function validate_money_positive(money: &Money) -> Result<(), ValidationError> apply qua #[validate(custom(function = "validate_money_positive"))] thay range(min = 1, max = ...) vì Money không phải primitive numeric. Lock vĩnh viễn từ B44: Newtype pattern MANDATORY cho mọi ID Shop API (UserId, ProductId, OrderId, CategoryId) — type safety zero-cost, compile xuống cùng layout u64 không có runtime overhead; #[serde(transparent)] MANDATORY mọi newtype — wire format raw primitive, client không thấy wrapper struct; Money = Decimal lock vĩnh viễn — KHÔNG bao giờ dùng f64 cho tiền (precision loss), KHÔNG dùng u64 thiếu scale; feature serde-with-str MANDATORY cho rust_decimal — JSON string format tránh JS precision loss với JSON number; #[serde(flatten)] dùng cho query DTO (Pagination reuse cross list endpoint), KHÔNG dùng cho domain entity (pitfall ordering + performance); BTreeMap<String, Value> lock cho extra metadata cần ordered output (test snapshot, audit log); HashMap<String, Value> chỉ khi order không quan trọng (cache lookup nội bộ); Custom validator function với #[validate(custom(function = "..."))] lock pattern khi rule phức tạp không match field attribute built-in (length/range/regex); file dto/types.rs lock cho cross-domain primitive wrapper — ID không thuộc domain riêng. Workspace state change: 1 file mới crates/shop-common/src/dto/types.rs (5 newtype), 1 file updated crates/shop-common/src/dto/mod.rs (thêm pub mod types; + re-export 5 newtype), 1 file updated crates/shop-common/src/dto/product.rs (refactor price: u64 → Money, thêm metadata: BTreeMap, thêm validate_money_positive custom function), workspace deps thêm rust_decimal = { version = "1", features = ["serde-with-str"] }. Suggested commit: B44: Newtype pattern + Money(Decimal) + dto/types.rs module + refactor CreateProductDto. Foundation cho B45 (Serialize skip None), B54 (Decimal precision deep dive), B58 (FromStr/Display impl cho ID newtype parse Path extractor), B62 (schema price NUMERIC type).

14/06/2026
12 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 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)ProductId(u64) — compiler ngăn truyền nhầm thứ tự argument mặc dù 2 type cùng layout u64.
  • 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 cho Pagination reuse 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 — HashMap KHÔNG ordered gây test snapshot fail; BTreeMap ordered alphabetical lock cho metadata cần stable.
  • Áp Money(Decimal) wrap rust_decimal::Decimal cho Shop API thay u64/f64 — precision chính xác + JSON string format tránh JS lose precision.
2

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_id không tồn tại; foreign key có thể fail (nếu products table 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_centsamount_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.

3

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 UserIdProductId:

#[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 — UserIdu64 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.

4

#[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".

5

Money(Decimal) — Money Type Safety

Tiền KHÔNG bao giờ nên đại diện bởi u64 hoặc f64. Hai pitfall:

  • f64 mấ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) thay 0.3 mong 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.
  • u64 không có scale — số 1000 có nghĩa 1000 VND hay 10 USD (1000 cent) hay 0.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 — JS Number là 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ành Decimal::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.

6

#[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 searchcategory:

{
  "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 Pagination 1 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 flatten dùng Map trung gian (typically BTreeMap hoặ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 Pagination có field tên trùng field ProductListQuery (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.

7

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 idevent_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 BTreeMap JSON 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.
8

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: Money thay u64 — type safety không trộn lẫn với quantity: u32, stock: u32; precision Decimal chính xác cho phép tiết kiệm sau decimal khi cần (vd 2500000.50 VND lẻ).
  • #[validate(custom(function = "validate_money_positive"))] thay range(min = 1, ...)Money không phải primitive numeric — validator built-in không impl HasLen/HasMin/HasMax cho Money. Custom function signature MANDATORY fn(&T) -> Result<(), ValidationError>.
  • metadata: BTreeMap<String, Value> với #[serde(default)] — empty map nếu client KHÔNG gửi field; BTreeMap stable order cho test snapshot Shop API.
  • KHÔNG dùng #[serde(flatten)] cho metadata trong CreateProductDto — 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 ProductId chỗ UserId service 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ặc UserId (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"
9

Tổng Kết

  • Newtype pattern: wrap primitive cho type safety — compiler ngăn truyền nhầm UserId chỗ ProductId mặc dù cùng layout u64.
  • 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) thay u64/f64 cho tiền — rust_decimal precision 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 Money qua feature serde-with-str tránh JS lose precision với JSON number lớn hơn 2^53 ("25000.00" thay 25000.00).
  • #[serde(flatten)]: merge field con vào parent — DRY cho Pagination reuse 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.
  • HashMap vs BTreeMap: BTreeMap lock cho metadata cần ordered output (test snapshot, audit log, HTTP cache); HashMap chỉ khi order không quan trọng.
  • 5 Newtype ID lock B44: UserId, ProductId, OrderId, CategoryId, Money — đầy đủ derive Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize + #[serde(transparent)].
  • File path lock B44: crates/shop-common/src/dto/types.rs chứa 5 newtype cross-domain primitive wrapper; refactor price: u64Money trong CreateProductDto; thêm metadata: BTreeMap<String, Value> + validate_money_positive custom 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).
10

Bài Tập Củng Cố

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

  1. 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ì.
  2. #[serde(transparent)] thay đổi gì về JSON output? So sánh wire format có và không attribute cho pub struct Email(pub String) với value "[email protected]".
  3. Tại sao Money dùng Decimal thay f64 hoặc u64? Cho ví dụ pitfall mỗi loại — cụ thể phép tính nào fail.
  4. #[serde(flatten)] có 3 pitfall nào? Liệt kê và giải thích impact mỗi pitfall trong test snapshot, performance, refactor.
  5. Khi nào dùng BTreeMap thay HashMap cho 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
  1. 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ư u64 thuần. Không có type identity riêng. Newtype pub 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 layout u64. 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
    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
    Newtype có type safety:
    pub 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
    Trade-off: newtype verbose hơn (phải wrap/unwrap qua .0 field 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.
  2. #[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:
    #[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]"}
    Key "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". #[serde(transparent)]:
    #[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]"
    Wire format giống String nguyên gốc — wrapper struct trong suốt. Attribute transparent bá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) → JSON 123), 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.
  3. 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:
    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 float
    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ố 1000 ambiguous semantic. Ví dụ:
    // 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×)
    Service B treat 1000 như 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:
    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ác
    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: Money(Decimal) MANDATORY, KHÔNG bao giờ f64 hoặc u64 cho tiền. Feature serde-with-str MANDATORY — JSON string format "25000.00" tránh JS lose precision với JSON number lớn hơn 2^53.
  4. #[serde(flatten)] 3 pitfall + impact. (a) Order field không stable: serde flatten dùng Map trung gian (typically BTreeMap hoặ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 (vd assert_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ùng assert_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). Profile cargo bench Shop API show overhead rõ ở P99 latency request body 50KB+. (c) Conflict field name runtime ERROR: nếu Pagination có field search trùng ProductListQuery.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.
  5. BTreeMap vs HashMap cho extra field — use case test snapshot. HashMap pitfall: 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. BTreeMap stable: B-tree với key comparison Ord trait — iterate trả field theo thứ tự sort alphabetical, stable mọi lần serialize. Test snapshot — viết test cả 2:
    use 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"}"#);
    }
    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 MANDATORY BTreeMap<String, Value>; CreateProductDto.metadata, future EventDto.extra webhook payload, AuditLog.metadata admin action data đều dùng BTreeMap.
11

Bài Tiếp Theo

#[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.