🎯 什麼情境該想到我
當你「需要在領域物件和訊息之間雙向搬移資料,又不想讓兩邊互相知道對方存在」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在領域物件與訊息之間做雙向對應,且對應邏輯獨立於兩者之外,領域物件與訊息彼此不知道對方。
做法 / 結構要點:
- 建立獨立的 Mapper,負責「領域物件 → 訊息」與「訊息 → 領域物件」的轉換。
- 領域物件不含任何訊息相關程式碼,訊息也不依賴領域類別。
- 由 Mapper(或呼叫它的層)處理型別、欄位、格式的映射。
★★ 核心紀律:領域物件與訊息是兩個型別,對應程式碼放在第三個地方
// ❌ 領域型別直接背著序列化細節
package order
type Order struct {
ID string `json:"id"` // ★ 領域模型被 wire format 汙染
Total Money `json:"total"`
}
// ★ 後果:改 JSON 欄位名要動領域型別;領域型別的重構會破壞線上契約
// ✅ 兩個型別,一個對應器
package order // 領域
type Order struct { ID string; Total Money; lines []Line } // ★ 可以有私有欄位與不變式
package ordermsg // 訊息
type OrderPlacedV1 struct {
OrderID string `json:"order_id"`
Total int64 `json:"total_cents"` // ★ wire format 可以用不同的表示
}
func FromDomain(o order.Order) OrderPlacedV1 { … } // ★ 純函式,好測
func ToDomain(m OrderPlacedV1) (order.Order, error) { … }★★ 為什麼值得多一個型別:領域模型與 wire format 的變動原因不同。
領域模型因業務規則而變;wire format 因下游相容性而變。
綁在一起 = 兩個變化軸打在同一個檔案上(見 發散式變化)。
★ 對應器該放在哪一邊
| 放哪 | 說明 |
|---|---|
| ❌ 領域 package | 領域會 import 訊息格式 → ★ 依賴方向錯了 |
| ❌ 訊息 package | 訊息 package 要 import 領域 → ★ 也不理想,但比較能接受 |
| ★ 獨立的 adapter/gateway package | ★ 推薦:它 import 兩邊,而兩邊都不認識它 |
★ 判準:領域 package 的 import 清單裡不該出現任何序列化或 broker 的東西。
這是一個可以直接檢查的規則(go list -deps 或 depguard linter)。
★★ 四個最容易在對應時出錯的地方
// ① ★★ 精度與型別
Total Money → TotalCents int64 // ✅ 整數分
Total Money → Total float64 // ★★ 0.1+0.2 != 0.3,帳務災難
// ② ★ 大整數在 JSON 裡失真
OrderID int64 → "order_id": 9007199254740993
// ★ JavaScript 的 Number 只有 53 bit 安全整數 → ★ ID 一律用字串
// ③ ★★ 零值與「沒給」不可區分
Discount int → "discount": 0 // ★ 沒折扣,還是忘了填?
// ✅ 需要區分時:*int,或明確的 HasDiscount bool
// ④ ★ 時間
CreatedAt time.Time → "created_at": "2026-09-25T10:00:00Z" // ✅ RFC3339 帶 offset
→ "created_at": "2026-09-25 10:00:00" // ★★ 沒時區,解析方各憑想像★★ round-trip 測試:這是這個模式唯一真正必要的測試
func TestRoundTrip(t *testing.T) {
orig := order.Order{ID: "o1", Total: money.FromCents(1999)}
got, err := ToDomain(FromDomain(orig))
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——
因為單向測試(只測 FromDomain)永遠不會發現它。
★ 搭配 property-based 測試(隨機生成領域物件)效果更好。
★ 版本共存:對應器是升級的正確位置
// ★ 在對應器裡把所有舊版升級成單一領域模型
func ToDomain(v int, data []byte) (order.Order, error) {
switch v {
case 2: var m OrderPlacedV2; …; return m.toDomain()
case 1: var m OrderPlacedV1; …; return m.upgrade().toDomain() // ★ V1 → V2 → 領域
}
return order.Order{}, fmt.Errorf("%w: 未知版本 %d", ErrInvalidMessage, v)
}★ 「業務邏輯只認識最新的領域模型」是這個模式的主要收益——
if version == 1 絕對不該出現在領域層(見 格式指示器)。
★ 兄弟辨析
| 差別 | |
|---|---|
| 訊息閘道 | ★ 還管 broker API、重試、ack;對應器只管型別轉換 |
| 訊息轉換器 | ★ 訊息 ↔ 訊息(跨系統);對應器是領域物件 ↔ 訊息(跨層) |
| 資料傳輸物件 | ★ PoEAA 的同一想法 |
| 工具-物件關聯映射模式 | 領域物件 ↔ 資料庫列的同一問題 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
類似 ORM 資料對應的概念,但對象是訊息。
-
對應規則變動時集中在 Mapper 一處維護。
-
避免把商業規則放進 Mapper,它只做結構轉換。
-
★★ 領域型別不要背 json tag。領域模型與 wire format 的變動原因不同,綁一起就是兩個變化軸打在同一檔案上。
-
★ 領域 package 的 import 清單裡不該有序列化或 broker 的東西。 這是可以用
depguard檢查的規則。 -
★★ round-trip 測試(
A → B → A)是這個模式唯一真正必要的測試。單向測試永遠發現不了不對稱。 -
★★ 四個出錯熱點:float64 金額、int64 ID 在 JSON 失真、零值歧義、時間沒帶時區。
-
★ 版本升級要在對應器裡做。
if version == 1不該出現在領域層。 -
對應器要是純函式(不碰 I/O),否則它就變成 內容擴充器。
-
不要為了「少一個型別」而共用。多一個型別的成本是一次性的,耦合的成本是持續的。
-
產生的程式碼(protobuf/sqlc)不要手改,對應邏輯寫在對應器裡。
🔗 相關工具
- 訊息閘道 Messaging Gateway(同家族,封裝訊息收發的方法介面)
- 標準資料模型 Canonical Data Model(對應的目標常是中立資料模型)
- 企業整合模式
- 訊息閘道 —— 還管 broker API
- 訊息轉換器 —— 訊息 ↔ 訊息
- 格式指示器 —— 版本升級的依據
- 資料傳輸物件 —— PoEAA 的同一想法
- 工具-物件關聯映射模式 —— 領域 ↔ 資料庫的同一問題
- 純資料類別 —— 為什麼 wire format 該保持笨