🎯 什麼情境該想到我
當「是否符合某個商業規則」的判斷邏輯散落在驗證、查詢、建立各處,你想把它抽成一個可重複使用、還能用 and/or/not 自由組合的物件時。
⚙️ 怎麼用(步驟 / 公式)
意圖:把商業規則做成可組合的謂詞物件,統一用於驗證、查詢篩選與建構。
- 把一條規則封裝成一個規格物件,核心是
isSatisfiedBy(candidate)這類判定方法。 - 提供組合運算:
and、or、not,讓小規格拼成複雜規則而不重寫邏輯。 - 一套規格可服務三種用途:驗證物件是否合規、查詢時篩選符合的物件、建構時描述要造出什麼樣的物件。
- 用領域語言命名規格(如
逾期未付訂單、高風險客戶),讓規則本身成為可溝通的概念。
★★ 它要解決的問題:業務規則散落在查詢、驗證、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。