🎯 什麼情境該想到我

當你「業務邏輯不複雜,想把每個動作直接寫成一段從頭到尾的程序」的時候。

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

意圖:把單一業務動作(一次請求 / 一筆交易)組織成一段程序式的指令稿,直接完成驗證、計算、存取資料庫等所有工作。

做法要點:

  • 每個業務動作對應一個程序(method / function),從輸入到輸出一條龍處理。
  • 可直接寫 SQL 存取資料庫,或透過 Gateway 存取。
  • 一個類別可放多個相關指令稿,或一個指令稿一個物件(Command)。
  • 特點是直覺、上手快、每個動作彼此獨立。

★ Go 的長相:這其實是 Go 社群的預設寫法

// ★ 一個業務操作 = 一個函式,從頭到尾看得完
func (s *Service) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (OrderID, error) {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil { return "", fmt.Errorf("begin: %w", err) }
    defer tx.Rollback()                                  // ★ 已 commit 後 Rollback 回 ErrTxDone,無害
 
    stock, err := s.q.WithTx(tx).GetStockForUpdate(ctx, req.SKU)   // ★ SELECT … FOR UPDATE
    if err != nil { return "", fmt.Errorf("get stock: %w", err) }
    if stock.Qty < req.Qty { return "", ErrInsufficientStock }
 
    if err := s.q.WithTx(tx).DecStock(ctx, req.SKU, req.Qty); err != nil { … }
    id, err := s.q.WithTx(tx).InsertOrder(ctx, …)
    if err != nil { … }
 
    return id, tx.Commit()
}

★★ 這就是交易指令稿,而且它在 Go 裡通常是對的選擇。
Go 沒有繼承、沒有 ORM 文化、標準庫的 database/sql 是明確的交易 + 明確的查詢
——整個語言的紋理都偏向「一個請求一個函式,從上讀到下」。

★★ 它的真正優點常被低估

優點說明
★ 一個操作的全貌在一個地方不必在五個類別間跳躍就能回答「下單到底做了什麼」
★ 交易邊界顯而易見BeginTx 到 Commit 之間就是交易,不像 ORM 的 Unit of Work 隱形
★ 效能可預測沒有延遲載入的 N+1、沒有你沒寫的查詢
★ 新人上手快不需要先學一套物件協作的約定
★ 好測只要能給 tx,整個函式就能測

★★ 什麼時候它會撐不住:三個可觀察的訊號

① ★★ 同一段業務規則出現在三個以上的指令稿裡
   「VIP 折扣」在 PlaceOrder、Requote、Renew 各寫一次,而且已經不一致了
   → ★ 這是最可靠的訊號([[重複程式碼]])

② ★ 單一指令稿超過 200 行、或嵌套超過三層
   → ★ 先試 [[提煉函式]];抽出來的還是「程序」就還好,
     ★ 但如果抽出來的東西一直需要同一組資料 → 那是 [[領域模型]] 在敲門

③ ★★ 你開始寫「if 狀態 == A 且 狀態 != B 且 …」的組合檢查
   → 狀態機的不變式散落在多個指令稿 ⇒ ★ 一定會有一個漏檢查

★ 判準(Fowler 自己的說法):業務邏輯的複雜度。
規則少而穩 → 交易指令稿;規則多、互相牽連、常變 → 領域模型。
★ 而「複雜度」的操作化定義就是上面三個訊號。

★ 演進路徑:不必一次改成領域模型

第 1 步 ★ 把重複的規則抽成純函式(★ 吃參數、回傳值、不碰 DB)
        func vipDiscount(tier Tier, subtotal Money) Money
        → ★ 這一步就解掉 90% 的「規則不一致」問題,成本極低

第 2 步 ★ 純函式群開始需要同一組資料 → 讓它們變成一個型別的方法
        → ★ 這時你已經有一個 [[值物件]] 或小型 [[領域模型]] 了

第 3 步 ★ 只有在「不變式必須被強制」時,才引入完整的聚合與 [[資料對應器]]

★★ 關鍵:不要因為「交易指令稿是最低階的模式」就急著升級。
多數服務一輩子停在第 1、2 步就是最佳解。

★ 兄弟辨析

差別
領域模型★ 資料與行為在一起;交易指令稿是資料(列)+程序(函式)分開
表格模組★ 一個型別處理「一整張表」的所有列(.NET DataSet 式);★ 今天幾乎不用
服務層★ 不是替代關係:服務層是「對外的操作邊界」,內部可以是交易指令稿

🧪 我實際套用的紀錄

  • (待填)

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

  • 業務變複雜時,指令稿之間會出現重複邏輯,難以維護與擴充。

  • 邏輯量大、規則多變、共用行為多時,改用領域模型。

  • 隨系統成長,重構成本會升高。

  • ★ 這不是「初階模式」。Go 生態裡它常常是正確答案,多數服務一輩子不需要 領域模型。

  • ★ 撐不住的訊號是「同一規則出現在三個以上指令稿且已不一致」,不是「行數太多」。

  • ★ 第一步永遠是「把重複規則抽成純函式」,成本極低、收益最大。不要跳到完整領域模型。

  • ★ 交易邊界要顯式而且只有一層。指令稿裡再開一個 BeginTx 就是巢狀交易,database/sql 不支援(要用 savepoint)。

  • ★ defer tx.Rollback() 是慣用寫法:commit 後再 Rollback 回傳 sql.ErrTxDone,無害。漏了它才會在早期 return 時洩漏連線。

  • 不要把 HTTP 的東西帶進指令稿(*http.Request、ResponseWriter)。指令稿吃 command struct、回傳結果(見 服務層)。

  • 狀態機的不變式散在多個指令稿 ⇒ 一定有一個漏檢查。 這是最該升級的情況。

  • 指令稿之間互相呼叫要小心交易傳遞:被呼叫者不該自己開交易,要收 tx。

🔗 相關工具