🎯 什麼情境該想到我
當你「業務規則複雜、多變,想用一組互相合作的物件來表達領域」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:建立一個由物件組成的網,每個物件同時承載資料與行為,共同表達業務領域的邏輯。
做法要點:
- 物件對應領域概念(如訂單、客戶、商品),把資料與相關行為放在一起。
- 物件之間互相合作、彼此參照,形成關聯網。
- 通常搭配 Data Mapper 處理持久化,讓領域物件不需知道資料庫。
- 適合規則複雜、需長期演進的系統;初期投資較高,長期報酬也高。
★★ Go 的現實:領域模型可行,但要放棄 ORM 的那一套
// ★ Go 的領域模型:小寫欄位守不變式、方法回傳新狀態或錯誤
package order
type Order struct {
id ID
status Status
lines []Line // ★ 未匯出 → 外部無法繞過方法改它
version int64 // ★ 樂觀鎖(見 [[樂觀離線鎖]])
}
func (o *Order) AddLine(sku SKU, qty int) error {
if o.status != StatusDraft { // ★★ 不變式集中在這裡
return fmt.Errorf("order %s: 狀態 %s 不可加品項", o.id, o.status)
}
if qty <= 0 { return ErrInvalidQty }
o.lines = append(o.lines, Line{SKU: sku, Qty: qty})
return nil
}
func (o *Order) Total() Money { // ★ 衍生值算出來,不存欄位
var t Money
for _, l := range o.lines { t = t.Add(l.Subtotal()) }
return t
}★★ Go 能做領域模型的關鍵是「未匯出欄位 + package 邊界」:
只要Order在自己的 package,外部只能透過方法改它——這就是不變式的守點。
⚠️ 同 package 內仍然改得動(Go 的可見性邊界是 package,不是 struct)。
★★ 但 Go 少了三個 ORM 提供的東西,你必須自己決定怎麼辦
| 缺什麼 | Go 的做法 |
|---|---|
| 識別對應(同一列只有一個物件) | ★ 不做。一個請求內明確只載入一次,用參數傳遞 |
| 工作單元(自動追蹤變更) | ★ 明確傳 tx,明確呼叫 repo.Save(ctx, tx, o) |
| 延遲載入 | ★ 不做。載入時就決定「要不要帶 lines」,用兩個方法(Get / GetWithLines) |
★★ 這不是缺點,是取捨:你失去了「改欄位就自動存」的便利,
換到「沒有你沒寫的查詢、沒有隱形的 N+1」。
→ ★ Go 社群壓倒性地選後者(見 工具-物件關聯映射模式)。
★★ 載入與儲存:聚合要整包進出
// ★ 載入:一次把整個聚合讀齊(★ 通常是 2–3 個查詢,不是 N+1)
func (r *Repo) Get(ctx context.Context, q Querier, id ID) (*Order, error) {
head, err := q.GetOrder(ctx, id)
if err != nil { … }
lines, err := q.ListOrderLines(ctx, id) // ★ 一次撈全部,不是逐筆
if err != nil { … }
return reconstitute(head, lines), nil // ★ 從資料重建領域物件
}
// ★ 儲存:整包寫回 + 樂觀鎖
func (r *Repo) Save(ctx context.Context, q Querier, o *Order) error {
n, err := q.UpdateOrder(ctx, UpdateOrderParams{
ID: o.id, Status: o.status, Version: o.version + 1,
ExpectedVersion: o.version, // ★★ WHERE version = $expected
})
if err != nil { return err }
if n == 0 { return ErrConcurrentModification } // ★ 影響 0 列 = 被別人改過了
…
}★★ reconstitute 是最容易被忽略的一環:
從資料庫重建物件時不該走 AddLine 那些驗證(資料庫裡的資料已經是有效的),
所以需要一個只給持久化層用的建構路徑。
→ ★ Go 的做法:同 package 內的未匯出函式 reconstitute(...)。
★★ 最重要的一段:聚合邊界決定一切
★ 一個聚合 = 一個「必須一起維持不變式」的物件群 + 一個根
→ ① ★★ 一個交易只改一個聚合(★ 這是最重要的一條規則)
② 聚合之間用 ID 參照,不用物件參照
③ 跨聚合的一致性用領域事件 + 最終一致(見 [[領域事件]])
★ 判準:這兩個東西「必須同時正確」嗎?
必須(訂單與它的品項、帳戶與餘額)→ 同一個聚合。
不必須(訂單與客戶資料)→ ★ 兩個聚合,用 ID 參照。
⚠️ 把整個系統做成一個大聚合,等於所有寫入都競爭同一個鎖——
這是領域模型最常見的效能災難。→ 見 工具-實體值物件與聚合、粗粒度鎖。
★ 兄弟辨析
| 差別 | |
|---|---|
| 交易指令稿 | ★ 資料與程序分開;★ 多數 Go 服務的正確選擇 |
| 主動記錄 | 物件自己會存自己 ⇒ ★ 領域邏輯與持久化混在一起 |
| 資料對應器 | ★ 領域模型的必要搭配(讓領域物件不認識資料庫) |
| 表格模組 | 一個型別處理一整張表 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
學習曲線與前期成本較高,簡單業務用它會過度設計。
-
需搭配物件關聯對應機制(Data Mapper 等),複雜度上升。
-
簡單邏輯改用交易指令稿或表格模組。
-
★★ 一個交易只改一個聚合。 違反它就是把整個系統變成一個大鎖。
-
★★ Go 少了 ORM 的識別對應/工作單元/延遲載入。不要試著自己實作它們——明確傳
tx、明確載入、明確儲存。 -
★
reconstitute要走「不驗證」的路徑。從資料庫重建時走AddLine那套驗證,會讓舊資料無法載入。 -
★ 未匯出欄位只在 package 外有效。同 package 內仍改得動——所以聚合要有自己的 package。
-
★ 衍生值要算出來,不要存欄位(
Total()而非total),否則會有兩個真相來源(見 以查詢取代暫時變數)。 -
★ 前期成本是真的:多一層對應、多一組型別、
reconstitute的樣板。規則不複雜時是過度設計。 -
不要讓領域型別背 json/db tag。那會讓領域模型被 wire format 與 schema 綁死(見 資料傳輸物件)。
-
聚合太大會變成 過大類別,而且所有寫入競爭同一個版本號。
🔗 相關工具
- 交易指令稿 Transaction Script(簡單業務時的替代方案)
- 服務層 Service Layer(在領域模型之上定義對外操作邊界)
- 資料對應器 Data Mapper(幫領域模型處理持久化)
- 企業應用架構模式
- 交易指令稿、表格模組 —— 另兩種組織方式
- 資料對應器 —— ★ 領域模型的必要搭配
- 主動記錄 —— 混合持久化的對照
- 樂觀離線鎖 —— 聚合的併發控制
- 粗粒度鎖 —— 聚合邊界與鎖粒度
- 值物件、金錢 —— 領域模型的基本材料
- 工具-實體值物件與聚合、領域事件 —— DDD 的聚合設計
- 工具-領域邏輯組織模式 —— 三種選擇的判準