🎯 什麼情境該想到我

當你「業務規則複雜、多變,想用一組互相合作的物件來表達領域」的時候。

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

意圖:建立一個由物件組成的網,每個物件同時承載資料與行為,共同表達業務領域的邏輯。

做法要點:

  • 物件對應領域概念(如訂單、客戶、商品),把資料與相關行為放在一起。
  • 物件之間互相合作、彼此參照,形成關聯網。
  • 通常搭配 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 綁死(見 資料傳輸物件)。

  • 聚合太大會變成 過大類別,而且所有寫入競爭同一個版本號。

🔗 相關工具