🎯 什麼情境該想到我
★ 分工:這頁講「邊界怎麼畫、為什麼」(模型問題);
粗粒度鎖 講「共用版本號的實作」,從屬對應 講「子實體怎麼存」,
領域模型 講「Go 的載入與儲存骨架」。
當一群關係緊密的物件必須「一起保持一致」(例如訂單與它的訂單明細、庫存與其批次),而你不確定該把一致性與交易的邊界畫在哪、對外又該從哪個物件進去操作時。
⚙️ 怎麼用(步驟 / 公式)
意圖:把一組實體與值物件框成一個一致性邊界,對外只透過聚合根存取。
- 找出天然要一起維持不變式的實體與值物件,圈成一個聚合。
- 指定其中一個實體為聚合根:它是唯一對外入口,外部只能持有並操作聚合根。
- 一次交易只保證單一聚合內的一致性——聚合內同步強一致。
- 跨聚合的關係用 ID 參照,不直接持有對方物件;跨聚合一致性走最終一致(如領域事件)。
- 讓聚合盡量小:邊界越小,並發衝突與交易範圍越可控。
★★ 四條規則,而第一條決定其餘三條
① ★★ 一個交易只改一個聚合
→ ★ 這是最重要的一條。它同時決定了:
★ 邊界該多大、鎖的粒度、以及「跨聚合要用事件」
② ★ 聚合之間用 ID 參照,不持有對方物件
type Order struct { customerID CustomerID } // ✅
type Order struct { customer *Customer } // ★★ 錯:載入訂單會拖出客戶
③ ★ 聚合根是唯一入口
→ ★★ 外部不可持有內部實體的指標(否則不變式失守)
④ ★ 聚合盡量小
→ ★ 邊界越小,並發衝突越少、載入越便宜
★★ ①的實際推導:★ 如果一個交易要改兩個聚合,
那你需要跨聚合的鎖 ⇒ 死鎖風險 ⇒ 而且兩個聚合的併發衝突會互相放大。
→ ★ 所以「一個交易改兩個聚合」是邊界畫錯的訊號,不是一個可接受的權衡。
★★ 怎麼「畫」邊界:從不變式反推(唯一可靠的方法)
★ 步驟:
① ★★ 列出所有「必須隨時成立」的規則(不變式)
★ 「訂單的品項小計之和 = 訂單總額」
★ 「一張訂單的品項數不得超過 100」
★ 「使用者的信用額度 ≥ 未結清訂單總額」 ← ★★ 注意這一條
② ★ 每條規則畫一個圈,圈住它需要讀寫的所有資料
③ ★★ 圈重疊的就合併;不重疊的就是不同聚合
★★ ③ 會讓你發現一個殘酷的事實:
★ 「信用額度 ≥ 未結清訂單總額」這條規則跨越了 Customer 與所有 Order
→ ★★ 嚴格照做 ⇒ Customer 和所有 Order 是一個聚合 ⇒ ★ 所有下單競爭同一把鎖
→ ★ 這時要重新談判這條規則:
a. ★★ 改成最終一致(先下單,事後檢查並處理超額)← ★ 業務通常可以接受
b. ★ 在 Customer 上維護一個 outstandingTotal 欄位(★ 用它做樂觀鎖)
c. ★ 維持強一致,接受吞吐限制(★ 只在真的必要時)
★★ 這是聚合設計最重要的一步:★ 把「技術上的鎖競爭」變成「業務上的一致性談判」。
★ 多數「必須強一致」的規則,在問清楚之後其實可以最終一致。
★★ Go 的落地:邊界怎麼被強制
package order // ★★ 一個聚合 = 一個 package(這是 Go 的強制力來源)
type Order struct {
id ID
lines []Line // ★ 未匯出 → 外部無法直接操作
version int64 // ★ 整個聚合共用一個版本([[粗粒度鎖]])
}
type Line struct { // ★ 匯出型別(要能讀),★★ 但沒有建構函式
SKU SKU
Qty int
}
// ★★ 唯一的修改入口
func (o *Order) AddLine(sku SKU, qty int) error {
if len(o.lines) >= maxLines { return ErrTooManyLines } // ★ 不變式②
…
}
func (o *Order) Lines() iter.Seq[Line] { return slices.Values(o.lines) } // ★ 不洩漏 slice★★ 三個 Go 特有的注意點:
① ★★ 同 package 內仍可繞過(Go 的邊界是 package,不是 struct)
→ ★ 所以聚合要有自己的 package,不要和五個聚合放在同一個
② ★★ Lines() 直接回傳 o.lines 會讓呼叫端繞過驗證(見 [[封裝集合]])
→ ★ 回傳 iter.Seq 或副本
③ ★ 版本號要在 repo 的唯一 Save 入口遞增(見 [[粗粒度鎖]])
★★ 跨聚合一致性:三種做法的實際代價
| 做法 | 一致性 | ★ 代價 |
|---|---|---|
| ★ 同一交易改兩個聚合 | 強 | ★★ 邊界畫錯的訊號;鎖競爭、死鎖 |
| ★★ 領域事件 + 最終一致 | 最終 | ★ 需要 Outbox、冪等、以及中間狀態對外可見 |
| ★ Saga(需要補償) | 最終 + 補償 | ★★ 最貴:補償本身會失敗、有些動作不可補償 |
★★ 「中間狀態對外可見」是最容易被低估的代價:
★ 「已下單但庫存還沒扣」這個狀態會被使用者看到——
而怎麼呈現它是產品設計問題,不是技術問題(見 工具-分散式事務的取捨)。
★★ 聚合太大的三個可觀察症狀
① ★★ 樂觀鎖衝突率高
★ 監控 ErrConcurrentModification 的發生率 → ★ 這是最客觀的訊號
② ★ 載入一個聚合要撈幾千列
★ 「客戶」聚合包含它的所有歷史訂單 → ★★ 載入成本隨時間線性成長
③ ★★ 一個聚合有五個以上不相關的不變式
→ ★ 那是 [[過大類別]] 在領域層的樣貌
★ ①是唯一能量化的:★★ 在 repo 的 Save 裡對 RowsAffected()==0 加一個計數器,
它會直接告訴你哪個聚合太大。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
聚合太大會造成交易鎖範圍過廣、並發衝突多、載入成本高。
-
想在一次交易改多個聚合通常是邊界畫錯的訊號——改用事件做最終一致。
-
外部繞過聚合根直接動內部物件,會讓不變式失守。
-
★★ 「一個交易改兩個聚合」是邊界畫錯的訊號,不是一個可接受的權衡。
-
★★ 從不變式反推邊界是唯一可靠的方法,而它會逼出「跨聚合的規則」——那時要重新談判一致性要求,而多數可以接受最終一致。
-
★★ 這是聚合設計最重要的一步:把技術上的鎖競爭變成業務上的一致性談判。
-
★★ 聚合要有自己的 package。 Go 的邊界是 package,同 package 內仍可繞過聚合根。
-
★★
Lines()直接回傳內部 slice 會讓呼叫端繞過所有驗證。 回傳iter.Seq或副本。 -
★★ 「中間狀態對外可見」是最容易被低估的代價(「已下單但庫存未扣」)——那是產品設計問題。
-
★ 聚合太大唯一能量化的症狀是樂觀鎖衝突率。 在 Save 裡對
RowsAffected()==0加計數器。 -
★ 聚合之間用 ID 參照,持有對方物件會讓載入一個聚合拖出另一個。
-
載入成本隨時間線性成長(客戶聚合含所有歷史訂單)是邊界錯誤。