🎯 什麼情境該想到我
★ 分工:這頁講「什麼邏輯不屬於任何實體,該獨立出來」(領域層);
服務層(PoEAA)講「交易邊界、授權、DTO 組裝」(應用層)。★★ 兩者是不同的層。
當某個重要的領域操作「不自然屬於任何一個實體或值物件」——例如轉帳牽涉兩個帳戶、計價牽涉多方規則——硬塞進其中一個物件會讓它職責失衡時。
⚙️ 怎麼用(步驟 / 公式)
意圖:把不屬於任何實體/值物件的重要領域操作,做成無狀態的服務。
- 先問:這動作能自然放進某個實體或值物件嗎?可以就別開服務。
- 不行、且它是個重要領域概念時,抽成領域服務。
- 讓服務無狀態:狀態仍留在實體與值物件裡,服務只承接一段操作。
- 以動詞/動作命名(如
TransferMoney、計算運費),並使用通用語言的詞彙。 - 介面用領域語言表達,實作可協調多個聚合完成該操作。
★★ 最重要的一件事:領域服務 ≠ 應用服務(而混用是最常見的錯誤)
★★ 三層「服務」,責任完全不同:
★ 應用服務(= [[服務層]])—— ★ 在領域層「之上」
★ 交易邊界、授權、載入聚合、呼叫領域邏輯、發事件、組 DTO
★★ 它「知道」自己在處理一個 use case
★★ 領域服務 —— ★ 在領域層「之內」
★ 表達一個「不屬於任何單一實體或值物件」的業務操作
★★ 它是純的(★ 不碰交易、不碰資料庫、不知道 use case)
★ 基礎設施服務 —— ★ 在領域層「之下」
★ 寄信、發訊息、存檔案(★ 透過 [[閘道]] 隔開)
★★ 判準:這個「服務」有沒有
ctx與 error 以外的 I/O?
有 → ★ 它是應用服務或基礎設施服務,不是領域服務。
★ 領域服務應該是純函式或純方法——這是它和另兩種最可靠的區分。
★★ 什麼邏輯真的屬於領域服務:三個特徵
① ★★ 它跨越多個聚合,但不改變它們
★ func CalculateShipping(o Order, dest Address, rates RateTable) (Money, error)
→ ★ 它需要訂單、地址、費率表;★★ 但它不「屬於」任何一個
② ★★ 它是一個業務上有名字的計算或決策
★ 「匯率轉換」「信用評分」「最佳路徑」「分帳」
→ ★ 領域專家會用一個名詞稱呼它
③ ★ 把它塞進任何實體都很勉強
★ order.CalculateShipping(dest, rates)?→ ★★ Order 為什麼要知道費率表?
★ address.CalculateShipping(order, rates)?→ ★ 更怪
→ ★★ 這個「塞哪裡都怪」的感覺就是領域服務的訊號
★★ 但要小心 ③ 的誤判:
★ 「塞哪裡都怪」也可能是「你漏了一個概念」。
★ CalculateShipping(o, dest, rates) 塞哪裡都怪
→ ★★ 也許缺的是一個 ShippingQuote 值物件:
func NewShippingQuote(o Order, dest Address, rates RateTable) (ShippingQuote, error)
→ ★ 先問「有沒有一個沒被命名的概念」,★★ 再決定是不是領域服務
★ 這是 提煉類別 在領域層的版本。
★★ Go 的實作:一個純函式就是領域服務
package pricing // ★★ 不需要 PricingService 型別
// ★ 純函式:吃值、吐值、不碰 I/O ⇒ 可以窮舉測試
func Quote(o order.Order, dest Address, rates RateTable) (Money, error) {
if len(o.Lines()) == 0 { return Money{}, ErrEmptyOrder }
…
}// ★ 需要注入依賴時才用 struct(★ 但依賴必須是「資料」而非「服務」)
type Calculator struct {
rates RateTable // ✅ 資料
now func() time.Time // ✅ 時鐘(可測)
// db *sql.DB // ★★ ❌ 有這個就不是領域服務了
}★★ 「依賴必須是資料而非服務」是關鍵紀律:
★ 一旦領域服務持有 repo 或 http client,它就變成應用服務,而且不再可窮舉測試。
→ ★ 正確做法:由應用服務先把資料查好,再傳給領域服務。
★★ 它最常見的濫用:變成貧血模型的藏身處
// ❌★★ 所有邏輯都在「服務」裡,實體只剩欄位
type OrderService struct{ … }
func (s *OrderService) CanCancel(o Order) bool // ★★ 這該是 o.CanCancel()
func (s *OrderService) CalculateTotal(o Order) Money // ★★ 這該是 o.Total()
func (s *OrderService) AddLine(o *Order, l Line) error // ★★ 這該是 o.AddLine()
// → ★ 這是 [[表格模組]]/[[純資料類別]] 的樣貌,而不是領域服務★★ 判準:這個操作只需要「一個聚合的資料」嗎?
是 → ★ 它是那個聚合的方法,不是領域服務。
需要多個聚合或外部資料(費率表、匯率)→ 才是領域服務。
★ 一個可操作的檢查:★★ 領域服務的第一個參數是不是一個聚合?
是,而且沒有其他參數 → 那個方法應該搬到聚合上(見 依戀情結)。
★ 命名:不要叫 XxxService
❌ ★ OrderService、PricingService、ShippingService
→ ★★ 「Service」沒有帶來任何資訊,而且它誘使你把所有東西都塞進去
✅ ★ pricing.Quote()、shipping.Router、settlement.Allocator
→ ★ 用「它做什麼」命名(★ 見 [[工具-Go命名慣例]]:★★ package 名已經是前綴)
★★ 「名字叫 Service 的東西會無限成長」——這和 過大類別 的
「名字含糊代表職責不明」是同一條規律。
★ 兄弟辨析
| 差別 | |
|---|---|
| 服務層(PoEAA) | ★ 應用層:交易、授權、DTO;★★ 不同的層 |
| 聚合、實體 | ★ 只需要單一聚合的邏輯屬於它們 |
| 工廠 | ★ 建立邏輯 |
| 規格 | ★ 「判斷」邏輯;領域服務常是「計算」邏輯 |
| 閘道 | ★ 基礎設施服務的封裝 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
別把本該放進實體的行為都倒進服務,導致「貧血模型」(物件只有資料沒有行為)。
-
領域服務屬於領域層,不要和應用服務(協調用例、交易、對外)混為一談。
-
領域服務應無狀態;需要保存狀態的是實體或值物件。
-
★★ 領域服務 ≠ 應用服務(服務層)。 判準:有沒有
ctx/error 以外的 I/O?有 → 它不是領域服務。 -
★★ 領域服務的依賴必須是「資料」而非「服務」。 持有 repo 或 http client 就變成應用服務,而且不再可窮舉測試。
-
★★ 判準:這個操作只需要一個聚合的資料嗎? 是 → 它是那個聚合的方法,不是領域服務。
-
★★ 「領域服務的第一個參數是一個聚合、而且沒有其他參數」→ 那個方法應該搬到聚合上(見 依戀情結)。
-
★★ 「塞哪裡都怪」也可能是「你漏了一個概念」。 先問有沒有一個沒被命名的值物件。
-
★ 不要叫
XxxService。 名字含糊代表職責不明,而且它會無限成長。用pricing.Quote()。 -
★ 領域服務是貧血模型最常見的藏身處:所有邏輯在「服務」裡,實體只剩欄位。
-
★ 正確做法是「應用服務先把資料查好,再傳給領域服務」。