🎯 什麼情境該想到我

當你「想讓領域物件和資料庫互不知道對方,中間由獨立一層負責搬資料」的時候。

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

意圖:在領域物件與資料庫之間插入一層對應器(Mapper),雙向搬移資料並保持兩者互相獨立、彼此無感。

做法要點:

  • Mapper 負責把物件存進資料庫、從資料庫還原成物件;領域物件本身不含任何持久化程式。
  • 領域模型可自由設計,不必遷就資料表結構(可處理阻抗不匹配)。
  • 通常搭配 Identity Map、Unit of Work、Lazy Load 等一起運作。
  • 多數成熟 ORM(如 Hibernate)採此模式。

★★ 它的價值只有一個:讓領域模型完全不知道資料庫

// ★ 領域物件:沒有 db、沒有 tag、沒有任何持久化痕跡
package order
type Order struct {
    id      ID
    status  Status
    lines   []Line
    version int64
}
func (o *Order) Cancel() error { … }     // ★ 純業務邏輯,可以用單元測試窮舉
 
// ★ 對應器:在另一個 package,它認識兩邊
package orderrepo
func (r *Repo) Get(ctx context.Context, q Querier, id order.ID) (*order.Order, error) {
    head, err := q.GetOrder(ctx, string(id))          // ★ 用 [[表格資料閘道]] 取資料
    if err != nil { … }
    lines, err := q.ListOrderLines(ctx, string(id))
    if err != nil { … }
    return order.Reconstitute(head, lines)            // ★★ 從資料重建領域物件
}

★★ 關鍵收益:領域邏輯的測試不需要資料庫。
Order.Cancel() 的所有分支都能用表格測試窮舉,跑幾毫秒。
★ 這是 主動記錄做不到的事,也是選它的唯一充分理由。

★★ Reconstitute 是最容易被忽略、也最關鍵的一環

// ★ 兩條不同的建構路徑,語義完全不同
func New(customerID CustomerID, lines []Line) (*Order, error) {   // ★ 新建 → 要驗證
    if len(lines) == 0 { return nil, ErrEmptyOrder }
    …
}
 
// ★★ 從資料庫重建 → 不驗證(資料庫裡的資料已經是有效的),且要能設定所有內部狀態
func Reconstitute(row db.Order, lines []db.OrderLine) (*Order, error) {
    st, err := ParseStatus(row.Status)      // ★ 只做「解析」層級的檢查
    if err != nil { return nil, fmt.Errorf("order %s: %w", row.ID, err) }
    return &Order{id: ID(row.ID), status: st, lines: toLines(lines), version: row.Version}, nil
}

★★ 沒有 Reconstitute 會出現兩種災難:
① 走 New 重建 → 舊資料因為新加的驗證規則而載入失敗(改了規則就撈不出舊訂單)。
② 把欄位改成匯出 → 不變式的守點消失了,領域模型退化成 純資料類別。
★ Go 的做法:Reconstitute 是領域 package 內的匯出函式,
只給持久化層用——★ 並在註解裡寫明「僅供持久化層使用,不做業務驗證」。

★★ Go 沒有 ORM,所以你要明確決定三件事

ORM 幫你做的★ Go 的明確做法
識別對應★ 不做。一個請求內只載入一次,用參數傳遞同一個 *Order
工作單元(自動追蹤變更)★ 明確呼叫 repo.Save(ctx, tx, o),交易由 服務層 開
延遲載入★ 不做。Get / GetWithLines 兩個方法,載入範圍在簽章上

★★ 「載入範圍寫在方法名上」是 Go 這條路最大的好處:
GetWithLines 一看就知道會多一次查詢——不會有「你沒寫的查詢」。

★★ 儲存:整包寫回 + 樂觀鎖

func (r *Repo) Save(ctx context.Context, q Querier, o *order.Order) error {
    s := o.Snapshot()                                   // ★ 領域物件提供一個唯讀快照
    n, err := q.UpdateOrder(ctx, db.UpdateOrderParams{
        ID: string(s.ID), Status: string(s.Status),
        Version: s.Version + 1, ExpectedVersion: s.Version,   // ★★ WHERE version = $expected
    })
    if err != nil { return fmt.Errorf("update order: %w", err) }
    if n == 0 { return order.ErrConcurrentModification }       // ★ 影響 0 列 = 被改過
    return r.saveLines(ctx, q, s)                              // ★ 子實體:delete-all + insert 或 diff
}

★★ 子實體(lines)的寫回是這個模式最髒的部分,三種做法:

① ★ 全刪全插(delete + insert all)—— 最簡單,但★ 會讓 line 的 ID 改變、
   而且外鍵參照它的東西會炸
② ★ diff(比對記憶體與資料庫)—— 正確但要自己實作,★ 這正是 ORM 的 [[工作單元]] 在做的事
③ ★★ 讓領域物件記錄「做了什麼」(append-only 的變更清單)
   → 最乾淨,但領域物件要背一點持久化意識

★ 實務上 ① 對「不被外部參照的子實體」是可接受的,而且最好維護。

★ 什麼時候不要用

  • ★ 物件結構 ≈ 資料表結構 → 用 表格資料閘道 就好,對應層是純成本。
  • ★ 領域邏輯簡單 → 交易指令稿。
  • ★ 團隊沒有共識 → 一半的程式碼走對應器、一半直接用 row struct,比兩者任一都糟。

★ 兄弟辨析

差別
主動記錄★ 物件自己會存自己;領域邏輯與持久化耦合
表格資料閘道★ 回傳扁平 row struct;★ 資料對應器通常建在它之上
儲存庫★ 對應器 + 「像集合一樣的查詢介面」
對應器一般化的「兩邊互不認識的轉換層」
中繼資料對應★ 用元資料驅動對應(ORM 的做法),而非手寫

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 實作與基礎設施複雜度較高,簡單應用會過度設計。

  • 通常需要框架支援才不至於手工成本過高。

  • 簡單 CRUD、物件貼近資料表時,主動記錄更划算。

  • ★★ 必須有一條「不驗證」的重建路徑(Reconstitute)。 走 New 重建會讓舊資料因新驗證規則而載入失敗。

  • ★★ 不要為了重建而把欄位改成匯出,那會讓不變式的守點消失,領域模型退化成 純資料類別。

  • ★★ Go 沒有工作單元/識別對應/延遲載入。明確傳 tx、明確載入、把載入範圍寫在方法名上(GetWithLines)。

  • ★★ 子實體的寫回是最髒的部分。全刪全插最簡單但會改變子實體 ID;diff 正確但要自己實作。

  • ★ 只有在「領域邏輯的測試需要脫離資料庫」時才值得。物件結構 ≈ 表結構時它是純成本。

  • ★ 團隊沒有共識比不用更糟:一半走對應器、一半直接用 row struct,兩套心智模型。

  • 樂觀鎖只能靠 RowsAffected() == 0 判斷,那不是錯誤。

  • 對應器不該有業務邏輯。它只做「資料 ↔ 物件」的轉換;判斷與規則屬於領域層。

🔗 相關工具