🎯 什麼情境該想到我

當你「想在領域層之上定義應用的操作邊界,給外部一個粗粒度的統一入口」的時候。

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

意圖:以一層服務定義應用可執行的操作集合,封裝業務邏輯、協調回應、管理交易,作為對外的粗粒度 API。

做法要點:

  • 每個服務方法對應一個應用使用案例(use case),供各種前端(UI、遠端呼叫、批次)共用。
  • 服務層負責交易控制與跨物件協調;真正的領域邏輯仍放在領域層。
  • 兩種取向:僅做委派協調的「領域外觀」,或把應用邏輯也放進來的「操作腳本」。
  • 讓多種呈現層共享同一組操作,避免業務邏輯外洩到 UI。

★★ 它不是「又一層」,它是「交易與授權的邊界」

// ★ 服務層的職責清單 —— 這五件事都不屬於領域模型,也不屬於 handler
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderView, error) {
    // ① ★ 授權(★ 不能放 handler —— 訊息/CLI 入口會繞過,見 [[服務啟動器]])
    if err := s.authz.Can(ctx, cmd.Actor, "order:create"); err != nil { return OrderView{}, err }
 
    // ② ★★ 交易邊界(★ 這是服務層最不可讓渡的職責)
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil { return OrderView{}, fmt.Errorf("begin: %w", err) }
    defer tx.Rollback()
 
    // ③ ★ 協調多個聚合/領域服務
    o, err := order.New(cmd.CustomerID, cmd.Lines)         // ★ 領域邏輯在領域層
    if err != nil { return OrderView{}, err }
    if err := s.orders.Save(ctx, tx, o); err != nil { … }
 
    // ④ ★ 發領域事件(走 Outbox,和交易同生死)
    if err := s.outbox.Enqueue(ctx, tx, o.Events()...); err != nil { … }
 
    if err := tx.Commit(); err != nil { … }
 
    // ⑤ ★ 組裝對外的 DTO(★ 不要回傳領域物件)
    return toView(o), nil
}

★★ 判準:這件事需要「知道整個操作」才能做嗎?
需要(交易邊界、授權、事件發布、DTO 組裝)→ 服務層。
不需要(單一實體的規則)→ 領域層。

★★ 兩種服務層,差別很大

薄的(orchestration only)厚的(domain logic in services)
業務規則放哪★ 領域物件裡★ 服務方法裡
搭配領域模型★ 交易指令稿(★ 其實就是它)
風險服務方法變成純轉發★★ 領域物件退化成 純資料類別(anemic)

★★ Fowler 明說兩種都可以,但要「一致地選一種」。
最糟的是混著用——同一條規則有時在服務層、有時在領域物件,於是兩邊都要看。

★ Go 的務實建議:從「厚的」開始(=交易指令稿),
等到重複的規則累積到三處以上,才把它們往領域物件推(見 交易指令稿 的演進路徑)。

★★ 邊界紀律:三個不可越界的方向

① ★★ 服務層不可以認識 HTTP/gRPC/訊息
   ❌ func (s *Service) PlaceOrder(w http.ResponseWriter, r *http.Request)
   ✅ func (s *Service) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderView, error)
   → ★ 這讓同一個方法能被 HTTP、訊息、CLI、排程器共用(見 [[服務啟動器]])

② ★★ 服務層不可以回傳領域物件
   ❌ 回傳 *order.Order → ★ 呼叫端可以呼叫它的方法、繞過服務層
   ✅ 回傳 [[資料傳輸物件]]/view struct

③ ★ 服務層不該有自己的狀態
   → ★ 它只持有依賴(db、repo、authz、outbox),不持有請求資料
   → 這樣它天生併發安全,可以是單一實例

★★ 最常見的兩個失敗

① ★★ 交易邊界洩漏到 handler
   handler 開交易 → 傳 tx 給 service → ★ 交易邊界由 HTTP 層決定
   → 後果:訊息入口忘記開交易;巢狀交易;「一個 HTTP 請求一個交易」變成隱性規則
   → ★ 交易邊界屬於服務層,一個服務方法 = 一個交易

② ★★ 服務方法變成 CRUD 轉發
   CreateOrder / GetOrder / UpdateOrder / DeleteOrder
   → ★ 這不是服務層,是 [[資料對應器]] 的別名
   → ★★ 服務方法的名字應該是「業務操作」:PlaceOrder / CancelOrder / Requote
      → 名字若是 CRUD 動詞,代表業務意圖在別的地方(通常在前端)

★ 兄弟辨析

差別
遠端外觀★ 專門為「遠端呼叫」做的粗粒度介面(減少往返);服務層是本地的邊界
領域模型★ 服務層之下;★ 服務層協調它,不取代它
交易指令稿★ 厚的服務層基本上就是它
外觀模式行程內的簡化入口(★ 沒有交易與授權的語義)
訊息閘道往外發訊息的封裝

🧪 我實際套用的紀錄

  • (待填)

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

  • 只有單一應用、單一前端且邏輯簡單時,多加一層是過度設計。

  • 要小心別把領域邏輯堆進服務層,讓領域模型變貧血。

  • 邊界劃分需依應用需求,切不好會變薄殼或肥服務。

  • ★★ 交易邊界屬於服務層,不屬於 handler。 洩漏出去 → 訊息入口會忘記開交易、會有巢狀交易。

  • ★★ 授權檢查要在服務層,不在 handler。 否則訊息/CLI 入口直接繞過(見 服務啟動器)。

  • ★★ 不要回傳領域物件,回傳 DTO/view。否則呼叫端能繞過服務層呼叫領域方法。

  • ★★ 服務方法的名字要是業務操作(PlaceOrder),不是 CRUD 動詞(UpdateOrder)。 是 CRUD 就代表業務意圖跑到前端去了。

  • ★ 薄的與厚的服務層都可以,但要一致。 混著用 = 同一條規則兩邊都要看。

  • ★ 服務層不該有請求狀態,只持有依賴,這樣它天生併發安全。

  • 服務層不可以認識 HTTP/gRPC/訊息的型別。 收 command struct、回傳 view struct。

  • 不要為了「有分層」而加服務層。只有一個 handler 直接呼叫一個 repo 時,它是純噪音。

🔗 相關工具