🎯 什麼情境該想到我

當你「有個小物件(如金額、日期範圍、地址)只依附主物件,想直接攤進主物件那一列」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:把一個物件的欄位攤平、內嵌進其擁有者對應的資料表列中,不為它另建一張表。

做法要點:

  • 例如把 Money 的 amount 與 currency 兩個欄位,直接加進擁有者的資料列。
  • 載入 / 儲存擁有者時,順帶把這些欄位組回 / 拆開成內嵌物件。
  • 適合小型、無獨立身分、通常不可變的值物件。
  • 讓資料表少一次 join,也便於直接對這些欄位下查詢。

★★ 它是「值物件」在資料庫層的自然對應

// ★ 領域層:一個值物件
type Money struct {
    cents    int64
    currency string
}
type Order struct {
    id    ID
    total Money       // ★ 沒有自己的 ID、沒有自己的表
}
-- ★★ 內嵌值:攤平成父表的欄位
CREATE TABLE orders (
    id             text PRIMARY KEY,
    total_cents    bigint NOT NULL,      -- ★ Money 的兩個欄位
    total_currency text   NOT NULL
);

★★ 判準:這個東西有身分嗎?
沒有(兩個 Money{1999,"TWD"} 完全可互換)→ 內嵌值。
有(同一個「地址」被兩個訂單參照,改了要一起變)→ ★ 那是實體,要自己的表。
→ 見 值物件、工具-實體值物件與聚合。

★★ 三種在資料庫裡表達「一組值」的方式

做法可查詢可加索引可加約束schema 演進
① ★ 攤平成欄位(內嵌值)★★ 完全可以★★ 可以★★ 可以★ 要 migration
② JSONB 一欄★ 可以但較慢★ 需要 GIN/表達式索引★ 只能靠 CHECK★★ 免 migration
③ ★ 複合型別(Postgres CREATE TYPE)可以★ 較麻煩可以★ ORM 支援差

★★ 選 ①,除非欄位真的是「不需要查詢的附加資料」(那時用 ② 並接受它是 序列化大物件)。
★ ①② 的判準:你會不會用它來 WHERE 或 ORDER BY? 會 → 攤平。

★★ Go 的實作:三個必做的事

// ① ★ 領域層的值物件保持不可變(小寫欄位 + 值接收者)
func (m Money) Add(o Money) (Money, error) {
    if m.currency != o.currency { return Money{}, ErrCurrencyMismatch }
    return Money{cents: m.cents + o.cents, currency: m.currency}, nil
}
 
// ② ★★ 對應器負責攤平/組回(★ 不要讓值物件自己知道資料庫)
func toRow(o *Order) db.Order {
    t := o.Total().Snapshot()
    return db.Order{ID: string(o.ID()), TotalCents: t.Cents, TotalCurrency: t.Currency}
}
func fromRow(r db.Order) (*Order, error) {
    m, err := money.New(r.TotalCents, r.TotalCurrency)   // ★ 重建時仍要驗證幣別合法
    …
}
 
// ③ ★★ 或讓值物件實作 Scanner/Valuer(單欄位的情況)
func (m Money) Value() (driver.Value, error)  { … }
func (m *Money) Scan(src any) error           { … }
// ⚠️ 但 Money 有兩個欄位 → ★ Scanner/Valuer 只適合「一個值物件對一個 DB 欄位」

★★ ③ 的限制很重要:sql.Scanner 是單欄位介面。
多欄位的值物件必須由對應器組裝——★ 這是 Go 裡最常撞到的一個限制。

★★ 四個實務陷阱

-- ① ★★ 欄位必須「一起有值或一起是 NULL」
total_cents bigint, total_currency text
-- ★ 一個有值一個 NULL → 資料庫裡出現「不可能存在的 Money」
-- ✅ 加約束
CHECK ((total_cents IS NULL) = (total_currency IS NULL))
 
-- ② ★★ 前綴命名,否則兩個內嵌值會撞名
billing_street,  billing_city,  billing_zip
shipping_street, shipping_city, shipping_zip
-- ★ 這也是 [[提煉類別]] 那一頁「命名前綴成群」訊號的來源
 
-- ③ ★ 可為空的內嵌值
-- Money 是 optional 時:★ 用 *Money,而三個欄位都可 NULL + 上面的 CHECK
-- → ★★ 不要用「cents = 0 代表沒有」—— 0 元和「沒有金額」是兩件事
 
-- ④ ★★ 加欄位要 migration(這是 ① 的代價)
-- Money 加一個 exchange_rate → ★ 所有內嵌它的表都要 ALTER
-- → ★ 這是「內嵌值被多處使用」時的真實成本

★★ ④ 是選擇 ① 的主要代價:值物件的形狀改變會波及所有內嵌它的表。
★ 判準:這個值物件的形狀穩定嗎? Money/DateRange 很穩;
「使用者偏好設定」不穩 → ★ 那用 JSONB。

★ 兄弟辨析

差別
值物件★ 領域層的概念;內嵌值是它的持久化方式
序列化大物件★ 整包序列化成一個欄位(不可查詢)
從屬對應★ 子物件在另一張表,且有多筆
外鍵對應子物件是獨立實體
金錢★ 最經典的內嵌值

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 內嵌物件無法被別人單獨參照或共享。

  • 資料量大、結構複雜或需獨立生命週期時不適合。

  • 若整組資料不需被查詢、只想整包存取,改用序列化大物件。

  • ★★ 欄位必須「一起有值或一起是 NULL」,要加 CHECK ((a IS NULL) = (b IS NULL)),否則資料庫裡會出現不可能存在的值物件。

  • ★★ sql.Scanner 是單欄位介面。多欄位的值物件必須由對應器組裝,這是 Go 裡最常撞到的限制。

  • ★★ 不要用「0 代表沒有」。0 元和「沒有金額」是兩件事,要用可為 NULL 的三欄位 + CHECK。

  • ★★ 值物件形狀改變會波及所有內嵌它的表(都要 ALTER)。形狀不穩定的就用 JSONB。

  • ★ 一定要前綴命名(billing_*/shipping_*),否則兩個內嵌值撞名。

  • ★ 判準:你會不會用它來 WHERE/ORDER BY? 會 → 攤平成欄位;不會 → JSONB(並接受它是 序列化大物件)。

  • ★ 重建(fromRow)時仍要驗證:資料庫裡可能有舊的、不合法的幣別代碼。

  • 不要讓值物件自己知道資料庫。攤平與組回是對應器的責任。

🔗 相關工具