🎯 什麼情境該想到我
當你「有個小物件(如金額、日期範圍、地址)只依附主物件,想直接攤進主物件那一列」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把一個物件的欄位攤平、內嵌進其擁有者對應的資料表列中,不為它另建一張表。
做法要點:
- 例如把 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)時仍要驗證:資料庫裡可能有舊的、不合法的幣別代碼。 -
不要讓值物件自己知道資料庫。攤平與組回是對應器的責任。
🔗 相關工具
- 序列化大物件 Serialized LOB(整組序列化存單一欄位的對照)
- 從屬對應 Dependent Mapping(另一種依附關係的處理)
- 企業應用架構模式
- 值物件 —— 領域層的概念
- 金錢 —— 最經典的內嵌值
- 序列化大物件 —— 整包存一欄
- 從屬對應 —— 子物件在另一張表
- 外鍵對應 —— 子物件是獨立實體
- 提煉類別 —— 「命名前綴成群」的訊號
- 工具-實體值物件與聚合 —— 值物件 vs 實體