🎯 什麼情境該想到我

當你的業務邏輯「散落各處、service 層越來越肥、不知道邏輯該放哪」時。

⚙️ 怎麼用(依業務複雜度選)

  1. Transaction Script(交易腳本):一個動作=一段程序式流程。簡單業務用它最直接,別過度設計。
  2. Domain Model(領域模型):把業務邏輯放進有行為的領域物件(充血模型),而非只有 getter/setter 的貧血物件 + 肥 service。複雜、多變的業務用它才管得住(呼應 領域驅動設計)。
  3. 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 社群不太做深度的領域建模;
    ★ 但「把規則收進型別裡並守護不變式」的核心價值仍然適用——用未匯出欄位 + 建構函式就能做到很多。

🔗 相關工具