🎯 什麼情境該想到我
當你「要跨程序傳輸多筆資料,想打包成一個物件一次傳完、減少往返」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把一次呼叫所需的多項資料,集中成一個可序列化的物件一次傳輸,降低昂貴的遠端往返次數。
做法要點:
- 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。