🎯 什麼情境該想到我

當你「某個物件只從屬於一個擁有者、不會被別人單獨參照,想讓擁有者幫它處理存取」的時候。

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

意圖:讓一個類別(擁有者)負責另一個「從屬類別」的載入與儲存,從屬物件沒有自己的獨立存取入口。

做法要點:

  • 從屬物件只能透過擁有者存取(例:訂單管理其訂單明細行)。
  • 擁有者被載入 / 儲存時,順帶處理其從屬物件;不需要為從屬類別建獨立的 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)」讓中間插入要重寫後面全部。變動頻繁的集合不適合。

🔗 相關工具