🎯 什麼情境該想到我
當你的業務邏輯「散落各處、service 層越來越肥、不知道邏輯該放哪」時。
⚙️ 怎麼用(依業務複雜度選)
- Transaction Script(交易腳本):一個動作=一段程序式流程。簡單業務用它最直接,別過度設計。
- Domain Model(領域模型):把業務邏輯放進有行為的領域物件(充血模型),而非只有 getter/setter 的貧血物件 + 肥 service。複雜、多變的業務用它才管得住(呼應 領域驅動設計)。
- Table Module:以「一張表對應一個類別」組織邏輯,介於兩者之間。
判斷:業務越複雜、規則越多變 → 越該用 Domain Model;越簡單 → 越該用 Transaction Script。 別為簡單 CRUD 硬套領域模型。
★ 三種模式的具體長相
① Transaction Script(交易腳本)
// 一個使用案例 = 一段從頭到尾的程序
func (s *Service) PlaceOrder(ctx context.Context, req Request) error {
items, err := s.repo.LoadItems(ctx, req.ItemIDs)
if err != nil { return err }
total := 0
for _, it := range items { total += it.Price * req.Qty[it.ID] }
if req.CouponCode != "" {
d, err := s.coupons.Lookup(ctx, req.CouponCode)
if err == nil && d.Valid() { total -= d.Amount }
}
// ... 驗證、扣庫存、建訂單、發事件
}★ 優點:從上到下讀完就知道發生了什麼。 對簡單流程這是最好的組織方式。
★ 缺點:規則散落在各個腳本裡,第五個使用案例會開始大量重複。
② Domain Model(領域模型)
// ★ 規則放在領域物件裡,而不是 service 裡
type Order struct {
items []LineItem
status Status
discount Discount
}
func (o *Order) AddItem(p Product, qty int) error {
if o.status != StatusDraft {
return ErrOrderNotEditable // ★ 不變式由物件自己守護
}
if qty <= 0 { return ErrInvalidQty }
o.items = append(o.items, LineItem{p, qty})
return nil
}
func (o *Order) Total() Money { /* 規則在這裡,只有一份 */ }★ 優點:規則只有一份,且「不可能建出不合法的 Order」。
★ 缺點:要設計、要對應到資料庫(見 工具-物件關聯映射模式)、學習曲線高。
③ Table Module:一個類別對應一張表,方法操作「整個表」而非單一列。
(.NET 的 DataSet 風格,現在少見;概念上接近「repository + 靜態方法」。)
★ 貧血模型 vs 充血模型:問題不在「有沒有行為」
// 貧血:只有欄位
type Order struct { Items []Item; Status string; Total int }
// 規則全在 service 裡
func (s *OrderService) AddItem(o *Order, p Product, q int) {
if o.Status != "draft" { ... } // ★ 這個規則在這裡
}
func (s *ShippingService) Ship(o *Order) {
if o.Status != "paid" { ... } // ★ 另一個地方也在判斷 Status
}★ 貧血模型的真正問題不是「沒有方法」,是「不變式沒有守護者」。
Order.Status是公開可寫的 → 任何人都能把它改成任何值,
而「哪些轉換是合法的」這個知識散落在所有碰它的地方(見 狀態模式)。
★ 但貧血模型不是「錯」:
對簡單的 CRUD,貧血模型 + Transaction Script 是正確且更簡單的選擇。
Martin Fowler 稱它為 anti-pattern,是針對「業務複雜卻還用貧血模型」的情況。
判準是業務複雜度,不是風格偏好。
★ 怎麼選(可操作的判準)
| 訊號 | 該用 |
|---|---|
| 主要是 CRUD + 少量驗證 | ★ Transaction Script |
| 同一個業務規則在 3 個以上的地方重複 | ★ 該往 Domain Model 移動 |
| 狀態轉換有明確規則,而且被多處檢查 | ★ Domain Model(+ 狀態模式) |
| 規則常變、需求方講的是「業務語言」 | ★ Domain Model(+ 工具-通用語言) |
| 讀多寫少、主要是查詢與報表 | ★ Transaction Script 或 CQRS 的讀側直接查 |
★ 最務實的路線:從 Transaction Script 開始,當規則開始重複時再把它往領域物件裡收。
不要一開始就建完整的領域模型——你那時還不知道規則長什麼樣。
★ CQRS:讀寫可以用不同的組織方式
寫側用 Domain Model(要守護不變式)、讀側直接查詢(不需要領域物件)。
// 寫:走聚合,守規則
order.AddItem(p, 3)
repo.Save(ctx, order)
// ★ 讀:直接一個 SQL 投影出前端要的形狀,不載入整個聚合
rows := db.Query(`SELECT o.id, o.total, c.name FROM orders o JOIN ... `)★ 這解掉了領域模型最大的實務痛點:為了顯示一個列表而載入一堆聚合,既慢又麻煩。
兩側用不同的模型是完全正當的(見 工具-領域邏輯組織模式 的上游 工具-實體值物件與聚合)。
🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意 / 什麼時候不適用
- ★★ 不要為簡單 CRUD 套領域模型。這是 DDD 最常見的誤用——
你會得到一堆只有 getter/setter 的「領域物件」+ 一層無意義的 mapper,複雜度全增、收益全無。 - ★ 領域模型需要好的持久化策略。物件模型與關聯模型的阻抗不匹配是真實的痛
(見 工具-物件關聯映射模式)。沒想清楚這一層就上領域模型,會卡在 ORM 地獄。 - ★ 領域物件不該知道資料庫。一旦
Order.Save()出現,它就不是領域模型了(那是 Active Record)。 - 聚合的邊界決定了交易邊界。一次交易只改一個聚合——跨聚合要用最終一致
(見 工具-實體值物件與聚合、工具-分散式事務的取捨)。 - 不同的限界上下文可以用不同的模式。訂單用領域模型、報表用交易腳本——這是對的,不是不一致
(見 工具-限界上下文)。 - Go 的慣例比較偏 Transaction Script。Go 社群不太做深度的領域建模;
★ 但「把規則收進型別裡並守護不變式」的核心價值仍然適用——用未匯出欄位 + 建構函式就能做到很多。
🔗 相關工具
- 工具-物件關聯映射模式 —— 領域模型怎麼落到資料庫
- 工具-實體值物件與聚合 —— 領域模型的建模單位與交易邊界
- 工具-限界上下文 —— 不同上下文可以用不同模式
- 工具-通用語言 —— 領域模型的命名來源
- 狀態模式 —— 狀態轉換的規則該放哪
- 工具-分散式事務的取捨 —— 跨聚合的一致性
- 企業應用架構模式