🎯 什麼情境該想到我

當你「要跨程序傳輸多筆資料,想打包成一個物件一次傳完、減少往返」的時候。

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

意圖:把一次呼叫所需的多項資料,集中成一個可序列化的物件一次傳輸,降低昂貴的遠端往返次數。

做法要點:

  • DTO 是單純的資料容器(一堆欄位+存取子),不含業務邏輯。
  • 必須可序列化,才能跨網路傳送。
  • 用一個組裝器(Assembler)在領域物件與 DTO 之間互轉,隔離兩者。
  • 一次呼叫就把整組資料帶過去/帶回來,取代多次細瑣呼叫。

★★ 它在 2002 年的理由消失了,但它今天更重要(理由不同)

★ 2002 年的理由:★ 減少遠端呼叫次數(一次帶一包資料回來)
→ ★★ 那個理由今天由 [[遠端外觀]] 承擔

★★ 今天的理由:★ 內部模型與對外契約必須能獨立演進
→ ① 領域模型重構(改欄位名、拆型別)★ 不該破壞 API
   ② API 演進(加欄位、改格式)★ 不該逼領域模型跟著改
   ③ ★★ 領域物件有未匯出欄位與不變式,序列化出去會破壞封裝
   ④ ★ 不同客戶端需要不同的欄位子集

★★ 判準:你願意讓「領域模型的重構」變成「API 的破壞性變更」嗎?
不願意 → 你需要 DTO。

★★ Go 的三層型別(這是本頁最重要的結論)

// ★ ① 領域模型 —— 未匯出欄位、有不變式、沒有任何 tag
package order
type Order struct { id ID; status Status; lines []Line; version int64 }
 
// ★ ② DB row —— 由 sqlc 產生,形狀等於資料表
package db
type Order struct { ID string; Status string; Version int64 }
 
// ★★ ③ API DTO —— 形狀等於線上格式
package api
type OrderResponse struct {
    ID     string       `json:"id"`
    Status string       `json:"status"`
    Total  string       `json:"total"`        // ★★ 金額用字串,避免 float 與 int64 失真
    Lines  []LineResp   `json:"lines"`        // ★ 一律初始化,不要 nil(見 [[轉換視圖]])
    PaidAt *time.Time   `json:"paid_at"`      // ★ 可為空用指標
}

★★ 「多一個型別」的抱怨是真的,但代價算清楚就很明顯:

★ 三個型別的成本:兩個對應函式(toDTO / fromDB),★ 各二十行,一次性
★ 合併成一個型別的成本:
  ① ★★ 改領域欄位名 = API 破壞性變更 = 所有客戶端要改
  ② ★★ 領域模型被迫全部匯出 ⇒ 不變式的守點消失
  ③ ★ API 想加一個「計算欄位」(is_overdue) → 領域型別被迫長出它
→ ★★ 後者是持續的、擴散的成本

★★ DTO 的六個設計規則

// ① ★★ 一律扁平或淺層 —— DTO 不該是領域模型的鏡像
//   ★ 深層巢狀讓客戶端要寫一堆 optional chaining
 
// ② ★★ 只放這個端點需要的欄位
//   ★ 「通用 DTO」會變成所有欄位的聯集(見 [[標準資料模型]] 的同一個陷阱)
//   → ★ 寧可有 OrderListItem 與 OrderDetail 兩個型別
 
// ③ ★★ ID 用字串、金額用字串或整數分、時間用 RFC3339
type Resp struct {
    ID     string `json:"id"`         // ★ int64 到 JS 會失真
    Amount string `json:"amount"`      // ★★ 或 int64 分 —— 絕不用 float
    At     time.Time `json:"at"`       // ★ Go 的 time.Time 序列化就是 RFC3339
}
 
// ④ ★★ 請求 DTO 與回應 DTO 要分開
//   ★ 共用一個 → 「建立時不該給 id」「更新時不該改 created_at」無法表達
type CreateOrderRequest struct{ … }    // ★ 沒有 ID、沒有 created_at
type OrderResponse      struct{ … }
 
// ⑤ ★ 驗證在 DTO → command 的轉換處
func (r CreateOrderRequest) toCommand() (order.PlaceCommand, error) { … }
//   → ★★ 領域層再驗一次(★ 不要相信「上層已經驗過了」)
 
// ⑥ ★★ 不要在 DTO 上掛方法做業務判斷
func (r OrderResponse) CanCancel() bool { … }   // ★★ 業務規則跑到 API 層了

★★ ④ 是最常被忽略的:用同一個型別收請求與回應,
你就無法表達「這個欄位是唯讀的」——於是要靠文件與人的記憶。

★★ 對映程式碼:要不要用工具產生

★ 手寫(推薦):
  ✅ ★ 明確、可讀、可以在轉換時做格式化與驗證
  ❌ 樣板多
★ codegen(goverter 之類):
  ✅ 樣板少
  ❌ ★★ 「欄位名一樣就自動對映」是隱性耦合 —— ★ 改領域欄位名會靜默改變 API
★ 反射(mapstructure 之類):
  ❌ ★★ 執行期才發現對映錯誤,而且效能較差

★★ 建議手寫,並配一個 round-trip 或 golden file 測試:

func TestOrderResponseGolden(t *testing.T) {
    got, _ := json.MarshalIndent(toDTO(sampleOrder()), "", "  ")
    // ★★ 比對 testdata/order_response.json
    // → ★ 任何 API 形狀的改變都會讓測試紅燈 ⇒ 破壞性變更不會悄悄發生
}

★ golden file 測試是保護 API 契約最便宜的手段——比任何文件或約定有效。

★ 兄弟辨析

差別
遠端外觀★ 減少往返的粗粒度介面;DTO 是它傳輸的資料
訊息對應器★ 領域物件 ↔ 訊息;DTO 是領域物件 ↔ API
對應器一般化的轉換層
純資料類別★ DTO 應該是純資料 —— 這是它唯一正當的形態
記錄集更早期的「一包資料」做法

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 沒有遠端邊界(同程序內)時,多做 DTO 與組裝是浪費。

  • DTO 與領域物件的重複欄位需維護同步,成本要納入考量。

  • 別把領域邏輯放進 DTO,它就該是笨資料。

  • ★★ 領域模型/DB row/API DTO 是三個型別。 合併的成本是持續且擴散的(改欄位名 = API 破壞性變更、領域模型被迫全部匯出)。

  • ★★ 請求 DTO 與回應 DTO 要分開,否則無法表達「這個欄位是唯讀的」。

  • ★★ 不要做「通用 DTO」,它會變成所有欄位的聯集。寧可有 OrderListItem 與 OrderDetail 兩個型別。

  • ★★ ID 用字串、金額用字串或整數分、絕不用 float、時間用 RFC3339。

  • ★★ 不要在 DTO 上掛業務判斷方法,那讓業務規則跑到 API 層。

  • ★★ 用 golden file 測試保護 API 契約——這是最便宜也最有效的手段,任何形狀改變都會紅燈。

  • ★ codegen 的「欄位名一樣就自動對映」是隱性耦合,改領域欄位名會靜默改變 API。建議手寫。

  • ★ 驗證在 DTO → command 的轉換處做,領域層再驗一次。 不要相信「上層已經驗過了」。

  • slice 欄位一律初始化為空,不要讓它序列化成 null。

🔗 相關工具