🎯 什麼情境該想到我

當你「要在兩個獨立子系統之間搬資料、又不想讓任一方知道對方存在」的時候。

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

意圖:在兩個獨立子系統之間搬移資料,兩邊都不知道對方的存在。

做法要點:

  • 由一個居中的對應器物件,負責在兩個子系統間讀取、轉換與傳遞資料。
  • 兩個子系統彼此沒有相依,也不知道對方;只有對應器同時認識雙方。
  • 是 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。

🔗 相關工具