🎯 什麼情境該想到我
當你「要在兩個獨立子系統之間搬資料、又不想讓任一方知道對方存在」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在兩個獨立子系統之間搬移資料,兩邊都不知道對方的存在。
做法要點:
- 由一個居中的對應器物件,負責在兩個子系統間讀取、轉換與傳遞資料。
- 兩個子系統彼此沒有相依,也不知道對方;只有對應器同時認識雙方。
- 是 Data Mapper(資料對應器,隔離領域物件與資料庫)的基型概念。
★★ 它和閘道的界線:誰認識誰
★ [[閘道]]:★ 我認識外面,外面不認識我(單向)
★★ 對應器:★ 兩邊都不認識對方,對應器認識兩邊(雙向隔離)
┌────────┐ ┌────────┐
│ 領域層 │ ←─ 對應器 ─→ │ 持久層 │
└────────┘ ↑ 只有它 import 兩邊 └────────┘
★★ 這決定了它該放在哪個 package:
不能放領域層(領域會 import 持久層的型別)、
不能放持久層(★ 較能接受,但仍讓持久層依賴領域),
★ 要放第三個 package(orderrepo/adapter)。
→ 判準:go list -deps ./internal/domain/...裡不該出現任何基礎設施套件。
★★ Go 的實作:對應器就是一組純函式
package orderrepo // ★ 它 import order 與 db,而那兩者互不認識
func toRow(o *order.Order) db.Order { … } // ★ 領域 → DB
func fromRow(r db.Order, ls []db.OrderLine) (*order.Order, error) { … } // ★ DB → 領域
// ★★ 對應器不該有 I/O、不該有狀態、不該有業務判斷
// → ★ 一旦它開始「去查別的東西來補資料」,它就變成 [[內容擴充器]]★★ 「純函式」這個限制帶來的三個好處:
① ★ 可以用表格測試窮舉邊界(NULL、零值、非法列舉值)
② ★★ round-trip 測試變得可能:toRow(fromRow(r)) 應該等於 r
→ ★ 這是抓出「精度損失、零值歧義、時區錯誤」唯一有效的方法
③ ★ 併發安全,不需要任何同步
★★ round-trip 測試:對應器唯一真正必要的測試
func TestRoundTrip(t *testing.T) {
orig := sampleDomainOrder()
row, lines := toRow(orig), toLineRows(orig)
got, err := fromRow(row, lines)
if err != nil { t.Fatal(err) }
if diff := cmp.Diff(orig, got, cmp.AllowUnexported(order.Order{})); diff != "" {
t.Errorf("round-trip 不對稱:
%s", diff)
}
}★★ A → B → A 拿不回原值,是對應器最常見也最難發現的 bug——
因為單向測試(只測 toRow)永遠不會發現它。
★ 常見的不對稱來源:
① ★★ float 精度(金額用 float64 → 0.1+0.2 != 0.3)
② ★★ 零值 vs NULL(Discount 0 存成 NULL,讀回來變 0 —— 看起來對,但語義變了)
③ ★ 時區(time.Time 存成 timestamp without time zone → 讀回來變 UTC)
④ ★ slice 的 nil vs 空(nil 存成 NULL,讀回來變空 slice)
⑤ ★★ 未匯出欄位漏了(加了新欄位但忘記在 toRow 裡處理 → 靜默丟失)
★ ⑤ 特別危險,而 round-trip + cmp.AllowUnexported 正好抓得到它。
★★ 什麼時候需要對應器(而不是直接用 row struct)
| 需要 | 不需要 |
|---|---|
| ★★ 領域模型有未匯出欄位與不變式 | ★ row struct 的形狀就是你要的 |
| ★ 領域結構與表結構差很多(一個聚合跨三張表) | 一對一 |
| ★★ 領域邏輯的測試要脫離資料庫 | 邏輯簡單、測試連 DB 可接受 |
| ★ 需要在邊界做版本升級(舊 schema → 新模型) | 無版本問題 |
★★ 判準:你有沒有「不想讓資料庫形狀決定領域模型形狀」的理由?
沒有 → 直接用 表格資料閘道 的 row struct,對應器是純成本。
★★ 一個常見的錯誤:雙向對應器變成單一個大函式
// ❌★★ 一個 Map 函式用 reflect 做雙向對應
func Map(src, dst any) error { … } // ★ 執行期才發現對應錯誤、無法測邊界
// ✅ ★ 每個方向一個具名的純函式★ codegen(goverter 之類)比反射好,但仍有隱性耦合:
「欄位名一樣就自動對映」→ 改領域欄位名會靜默改變 DB 對映(見 資料傳輸物件)。
★ 兄弟辨析
| 差別 | |
|---|---|
| 閘道 | ★ 單向封裝外部資源 |
| 資料對應器 | ★ 對應器用在「領域 ↔ 資料庫」的特化 |
| 訊息對應器 | ★ 用在「領域 ↔ 訊息」的特化 |
| 資料傳輸物件 | ★ 對應器的一個端(領域 ↔ API) |
| 配接器模式 | ★ 介面轉介面(★ 對應器是資料轉資料) |
| 中繼資料對應 | 用元資料驅動對應 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
比閘道更嚴格:閘道的一方知道另一方,對應器則要求雙方都不知情。
-
需要完全解耦時才值得;若一方可以合理依賴另一方,用較簡單的做法即可。
-
對應器本身會累積轉換邏輯,需留意其複雜度。
-
★★ 對應器要放第三個 package,兩邊都不 import 它。
go list -deps ./internal/domain/...裡不該出現任何基礎設施套件。 -
★★ round-trip 測試(
A → B → A)是唯一真正必要的測試。 單向測試永遠發現不了不對稱。 -
★★ 五個不對稱來源:float 精度、零值 vs NULL、時區、nil vs 空 slice、以及「加了欄位但忘記在對應函式裡處理」。
-
**★ 用
cmp.AllowUnexported才抓得到「漏了未匯出欄位」**這種靜默資料丟失。 -
★★ 對應器必須是純函式(無 I/O、無狀態、無業務判斷)。一旦它去查別的東西補資料,它就變成 內容擴充器。
-
★ 反射式的
Map(src, dst)執行期才發現錯誤且無法測邊界。 每個方向一個具名純函式。 -
★ codegen 的「欄位名一樣就對映」是隱性耦合,改欄位名會靜默改變對映。
-
★ 沒有「不想讓資料庫形狀決定領域模型」的理由 → 不要對應器,直接用 row struct。
🔗 相關工具
- 閘道 Gateway(封裝外部存取,但呼叫方知道閘道)
- 分離介面 Separated Interface(用介面切開相依)
- 企業應用架構模式
- 閘道 —— 單向封裝
- 資料對應器、訊息對應器 —— 兩個特化
- 資料傳輸物件 —— 領域 ↔ API 的一端
- 配接器模式 —— 介面轉介面
- 中繼資料對應 —— 元資料驅動
- 內容擴充器 —— 有 I/O 就變成它