🎯 什麼情境該想到我
當你「某個物件只從屬於一個擁有者、不會被別人單獨參照,想讓擁有者幫它處理存取」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓一個類別(擁有者)負責另一個「從屬類別」的載入與儲存,從屬物件沒有自己的獨立存取入口。
做法要點:
- 從屬物件只能透過擁有者存取(例:訂單管理其訂單明細行)。
- 擁有者被載入 / 儲存時,順帶處理其從屬物件;不需要為從屬類別建獨立的 Mapper。
- 從屬物件通常沒有自己的識別欄位對外,身分依附於擁有者。
- 前提是「只有一個擁有者、且無外部參照」。
★★ 它的定義比聽起來嚴格:三個必要條件
① ★★ 子物件「只」屬於一個父 —— 不被任何其他物件參照
② ★★ 子物件沒有獨立的身分 —— 外界不會用 ID 直接指向它
③ ★★ 子物件的生命週期完全依附父 —— 父刪則子刪
★★ 這三條合起來,就是 DDD 的「聚合內部的實體」。
★ 只要有一條不成立(例如報表要用 line_id 直接查),它就不是從屬物件,
而是一個需要自己的 repo 與身分的實體(見 工具-實體值物件與聚合)。
★ Go 的實作:父負責全部讀寫,子不匯出建構方式
package order
type Order struct {
id ID
lines []Line // ★ 未匯出:外部只能透過 Order 的方法操作
}
type Line struct { // ★ 匯出型別(要能被讀),但沒有 New 建構函式
SKU SKU
Qty int
}
func (o *Order) AddLine(sku SKU, qty int) error { … } // ★ 唯一的建立路徑
func (o *Order) RemoveLine(i int) error { … }
func (o *Order) Lines() []Line { return slices.Clone(o.lines) } // ★★ 回傳副本★★ slices.Clone 那一行是關鍵:直接回傳 o.lines 讓呼叫端可以
o.Lines()[0].Qty = 99 而繞過所有驗證(見 封裝集合)。
★ 更省的做法(Go 1.23+):回傳 iter.Seq[Line],不複製也不洩漏。
★★ 寫回資料庫:三種做法與各自會壞在哪
// ★★ ① 全刪全插(delete-all + insert-all)
DELETE FROM order_lines WHERE order_id = $1;
INSERT INTO order_lines (order_id, seq, sku, qty) SELECT $1, …;
// ✅ 最簡單、最好維護;★ 而且「從屬」的定義保證沒有外部參照它
// ★★ 但三個前提必須成立:
// ① 子表沒有被外部參照(★ 這正是「從屬」的定義,所以安全)
// ② 子表沒有需要保留的欄位(created_at、id)
// ③ ★ 列數不大(幾十到幾百)—— 幾萬列時 WAL 與鎖的成本不可忽略
// ★ ② diff(比對記憶體與資料庫)
// → 正確但要多一次查詢,而且要自己寫比對邏輯(★ 這就是 ORM 的 [[工作單元]])
// ★ ③ 位置鍵(seq)+ UPSERT
INSERT INTO order_lines (order_id, seq, sku, qty) VALUES …
ON CONFLICT (order_id, seq) DO UPDATE SET sku = EXCLUDED.sku, qty = EXCLUDED.qty;
DELETE FROM order_lines WHERE order_id = $1 AND seq > $2; -- ★ 刪掉多餘的尾巴
// ★ 冪等、不需先查;★★ 但「seq 是位置」意味著中間插入會重寫後面全部★★ ① 是從屬對應的標準答案,而且它之所以安全正是因為「從屬」的定義
(沒有外部參照)。→ ★ 這是整本書裡「模式定義直接決定實作簡化」最漂亮的一個例子。
★★ 併發:從屬物件必須用父的版本號
★ 子表自己有版本號 ⇒ 兩個人改不同的 line,都通過檢查
→ ★★ 但「訂單總額」這個跨子項的不變式被破壞了
→ ★ 正確做法:版本在父(聚合根)上 ⇒ 改任何子項都要遞增父的版本
→ 這就是 [[粗粒度鎖]]
★★ 最常見的誤判:它其實不是從屬物件
★ 三個「看起來像從屬但不是」的訊號:
① ★★ 有人需要用 line_id 直接查
GET /order-lines/{id} 或報表 JOIN order_lines 而不經過 orders
→ ★ 那它有獨立身分了,全刪全插會讓外部參照失效
② ★★ 它被兩個父共用
「地址」同時被訂單與使用者參照 → ★ 那是一個獨立實體或 [[值物件]]
③ ★ 它有自己的狀態機
order_line 有 shipped / returned / refunded 狀態,各自流轉
→ ★★ 那它有自己的生命週期 ⇒ 不是從屬
★★ ①② 的後果很具體:全刪全插會讓 line_id 改變 →
外部的參照(出貨紀錄、退款紀錄)指向不存在的列,而且沒有外鍵約束會抓到它
(因為你為了效能沒建外鍵,或因為跨服務)。
★ 兄弟辨析
| 差別 | |
|---|---|
| 外鍵對應 | ★ 子物件有獨立身分,可被外部參照 |
| 內嵌值 | ★ 子物件存在父的同一列裡(不是另一張表) |
| 序列化大物件 | ★ 整包子物件序列化成父的一個欄位 |
| 關聯表對應 | 多對多,子項被多個父共用 |
| 粗粒度鎖 | ★ 從屬物件的併發控制 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
一旦有別的物件也要參照該從屬物件,就不能用從屬對應。
-
從屬物件不能被多個擁有者共享。
-
從屬物件需要獨立查詢 / 生命週期時,改用一般的外鍵對應。
-
★★ 三個必要條件缺一不可:只屬於一個父、沒有獨立身分、生命週期完全依附。少一條它就是獨立實體。
-
★★ 有人需要用
line_id直接查 → 它不是從屬物件。 全刪全插會讓外部參照指向不存在的列。 -
★★ 全刪全插之所以安全,正是因為「從屬」的定義保證沒有外部參照。 誤判定義就會出事。
-
★ 全刪全插會重置
created_at/id,並在幾萬列時產生大量 WAL 與鎖。 -
★★ 版本號要放在父(聚合根)上,不要讓子項各有版本,否則跨子項的不變式守不住(見 粗粒度鎖)。
-
★
Lines()要回傳副本或迭代器,直接回傳內部 slice 讓呼叫端繞過所有驗證。 -
子項有自己的狀態機 → 它有自己的生命週期,不是從屬。
-
「位置鍵(seq)」讓中間插入要重寫後面全部。變動頻繁的集合不適合。
🔗 相關工具
- 外鍵對應 Foreign Key Mapping(從屬物件需獨立參照時的替代)
- 內嵌值 Embedded Value(更貼合、直接攤進同一列的作法)
- 企業應用架構模式
- 外鍵對應 —— 子物件有獨立身分
- 內嵌值 —— 存在父的同一列
- 序列化大物件 —— 整包序列化成一欄
- 關聯表對應 —— 被多個父共用
- 粗粒度鎖 —— 從屬物件的併發控制
- 封裝集合 —— 回傳副本或迭代器
- 工具-實體值物件與聚合 —— 聚合內部實體的定義