🎯 什麼情境該想到我
當你「想讓領域物件和資料庫互不知道對方,中間由獨立一層負責搬資料」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在領域物件與資料庫之間插入一層對應器(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判斷,那不是錯誤。 -
對應器不該有業務邏輯。它只做「資料 ↔ 物件」的轉換;判斷與規則屬於領域層。
🔗 相關工具
- 主動記錄 Active Record(較簡單、耦合較高的替代方案)
- 工作單元 Unit of Work(常與資料對應器搭配管理變更)
- 識別對應 Identity Map(常搭配以避免重複載入)
- 企業應用架構模式
- 主動記錄 —— 耦合持久化的對照
- 表格資料閘道 —— ★ 資料對應器通常建在它之上
- 儲存庫 —— 加上集合式查詢介面
- 對應器 —— 一般化的轉換層
- 中繼資料對應 —— ORM 的元資料驅動做法
- 領域模型 —— 它服務的對象
- 工作單元、識別對應、延遲載入 —— ORM 會幫你做、Go 要自己決定的三件事
- 樂觀離線鎖 —— 寫回時的併發控制