🎯 什麼情境該想到我

當「是否符合某個商業規則」的判斷邏輯散落在驗證、查詢、建立各處,你想把它抽成一個可重複使用、還能用 and/or/not 自由組合的物件時。

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

意圖:把商業規則做成可組合的謂詞物件,統一用於驗證、查詢篩選與建構。

  1. 把一條規則封裝成一個規格物件,核心是 isSatisfiedBy(candidate) 這類判定方法。
  2. 提供組合運算:and、or、not,讓小規格拼成複雜規則而不重寫邏輯。
  3. 一套規格可服務三種用途:驗證物件是否合規、查詢時篩選符合的物件、建構時描述要造出什麼樣的物件。
  4. 用領域語言命名規格(如 逾期未付訂單、高風險客戶),讓規則本身成為可溝通的概念。

★★ 它要解決的問題:業務規則散落在查詢、驗證、UI 三個地方

★ 「逾期未付款的訂單」這條規則出現在:
  ① ★ 資料庫查詢:WHERE status='placed' AND due_date < now()
  ② ★ 領域驗證:if o.Status == StatusPlaced && o.DueDate.Before(now) { … }
  ③ ★ UI 顯示紅色:order.isOverdue
→ ★★ 三份實作,而且★ 其中一份的邊界條件不一樣(<= 還是 <?時區?)

★★ 規格模式的承諾:把這條規則變成一個有名字的物件,三處共用它。
★ 而它在實務上只有一半能實現——見下面的「雙重實作問題」。

★ Go 的實作:一個純函式就是規格

// ★★ Go 不需要「Specification 介面 + isSatisfiedBy 方法」那套樣板
type OrderSpec func(Order) bool
 
func IsOverdue(now time.Time) OrderSpec {
    return func(o Order) bool {
        return o.Status == StatusPlaced && o.DueDate.Before(now)
    }
}
 
// ★ 組合(Evans 的 and/or/not)
func And(specs ...OrderSpec) OrderSpec {
    return func(o Order) bool {
        for _, s := range specs { if !s(o) { return false } }
        return true
    }
}
func Not(s OrderSpec) OrderSpec { return func(o Order) bool { return !s(o) } }

★★ Go 的頭等函式讓這個模式退化成「一個 func 型別」——
★ 和 策略模式 一樣(見該頁)。不需要介面、不需要類別階層。

★★ 雙重實作問題:這是規格模式最大的現實限制

★★ 一個 OrderSpec func(Order) bool 無法變成 SQL WHERE 子句。
→ ★ 你不能「載入一百萬筆訂單,在記憶體裡過濾」
→ ★★ 所以你還是需要一個 SQL 版本 ⇒ ★ 同一條規則兩份實作

★ 三種處置,按實用度排序:

① ★★ 接受兩份,但用「同一組測試」綁住它們(★ 最實用)
   func TestOverdueSpecMatchesSQL(t *testing.T) {
       // ★ 準備 20 筆邊界案例寫進測試資料庫
       // ★★ 斷言「SQL 查出來的集合」== 「用 spec 過濾記憶體集合的結果」
   }
   → ★ 這不消除重複,★★ 但讓「兩份不一致」變成 CI 紅燈

② ★ 規格自己能產生 SQL 片段
   func (s overdueSpec) SQL() (string, []any) { return "status = $1 AND due_date < $2", … }
   → ★ 可行,但★★ 它就是一個小型 [[查詢物件]],而且組合時佔位符編號很痛

③ ★★ 承認它們是兩層,各自命名
   ★ 讀側:q.ListOverdueOrders(ctx)(SQL,[[表格資料閘道]])
   ★ 寫側:IsOverdue(now)(order)(記憶體,領域規則)
   → ★ 這是 CQRS 的立場:★★ 讀與寫本來就該用不同的實作

★★ ① 是我會選的:★ 接受重複、用測試綁住。
「消除重複」在這裡的代價(自製 SQL 產生器)高於「用測試保證一致」。

★★ 什麼時候規格模式真的值得

✅ ★★ 規則會被「組合」
   ★ 「VIP 且逾期」「非 VIP 或金額大於一萬」
   → ★ 組合是規格模式唯一不可替代的優勢

✅ ★★ 規則由使用者定義(動態)
   ★ 「讓管理員自己設定促銷條件」→ ★ 規格可以被序列化、被儲存、被組合
   → ★★ 但這時你其實在做一個小型規則引擎,★ 要認清那個複雜度

✅ ★ 同一條規則在「驗證」與「篩選」兩處用
   → ★ 這是它最常見的正當理由

❌ ★★ 只有一處用、而且不組合
   → ★ 一個 method 就好:func (o Order) IsOverdue(now time.Time) bool
   → ★★ 引入 Specification 型別是 [[誇誇其談通用性]]

★★ 判準:這些規則會被「and/or/not」組合嗎? 不會 → 用方法,不要用規格。

★★ 一個常被忽略的用途:規格作為「意圖的紀錄」

// ★ 比起 if 條件式,具名規格讓「為什麼」留在程式碼裡
var (
    // ★★ 逾期 = 已下單但超過付款期限。不含草稿(還沒承諾)與已取消。
    IsOverdue = …
    // ★★ 可退款 = 已付款、未出貨、且在 7 天內(消保法規定)
    IsRefundable = …
)

★ IsRefundable 旁邊那句註解(「消保法規定」)是最有價值的部分——
★★ 它是 if 條件式放不進去的東西(見 註釋 的第③類)。

🧪 我實際套用的紀錄

  • (待填)

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

  • 把規格直接翻成記憶體謂詞去篩選大量資料,效能可能很差;查詢場景常需轉譯成 SQL/查詢條件。

  • 規則很簡單且只用一次時,直接寫條件即可,硬套規格是過度設計。

  • 別讓規格膨脹成什麼都做的萬用物件,一個規格表達一個清楚的判準。

  • ★★ 雙重實作問題無法完全消除:func(Order) bool 變不成 SQL WHERE。務實解是「接受兩份 + 用同一組測試綁住」,讓不一致變成 CI 紅燈。

  • ★★ 判準:這些規則會被 and/or/not 組合嗎? 不會 → 用一個方法(o.IsOverdue(now)),引入 Specification 型別是過度設計。

  • ★ Go 的頭等函式讓這個模式退化成一個 func 型別,不需要介面與類別階層(同 策略模式)。

  • ★ 讓規格自己產生 SQL 是可行的,但它就變成一個小型 查詢物件,組合時佔位符編號很痛。

  • ★ 使用者自訂規則時你其實在做規則引擎,要認清那個複雜度。

  • ★★ 具名規格最被低估的價值是「記下為什麼」(IsRefundable 旁邊的「消保法規定」),那是 if 放不進去的東西。

  • ★ 時間要當參數傳入(IsOverdue(now)),不要在規格裡呼叫 time.Now()——否則測試無法控制邊界。

  • 不要把規格用在「效能敏感的大集合篩選」上。那要走 SQL。

🔗 相關工具