🎯 什麼情境該想到我

當你「需要在領域物件和訊息之間雙向搬移資料,又不想讓兩邊互相知道對方存在」的時候。

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

意圖:在領域物件與訊息之間做雙向對應,且對應邏輯獨立於兩者之外,領域物件與訊息彼此不知道對方。

做法 / 結構要點:

  • 建立獨立的 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)不要手改,對應邏輯寫在對應器裡。

🔗 相關工具