WeHelp
Design Pattern 實戰:用策略模式(Strategy Pattern)重構巢狀 if-else,讓商業規則可以獨立測試
2026-08-18 11:09:04
這次直接用一個實際遇過的案例,來看看 **Strategy Pattern(策略模式)到底可以解決什麼問題**。 先看一段簡化過的程式碼。 實際專案中的商業邏輯和命名不能直接公開,所以以下只保留原本程式的結構。 ```csharp public Result ProcessCheckIn(Member member, CheckInEvent evt, DateTime today) { if (evt.Rule == CheckInRule.Continuous) { var records = db.CheckInRecords .Where(x => x.MemberId == member.Id) .ToList(); if (continuousDays > 0) { if (reward != null) { db.RewardRecords.Add(CreateReward(member, reward)); db.SaveChanges(); return Result.Success(); } } } else if (evt.Rule == CheckInRule.General) { var alreadySigned = db.CheckInRecords .Any(x => x.MemberId == member.Id && x.Date == today); if (!alreadySigned) { if (isSpecialDate) { if (specialReward != null) { db.RewardRecords.Add(CreateReward(member, specialReward)); db.SaveChanges(); return Result.Success(); } } else { if (reward != null) { db.RewardRecords.Add(CreateReward(member, reward)); db.SaveChanges(); return Result.Success(); } } } } else if (evt.Rule == CheckInRule.Combined) { // 更多規則…… } return Result.Fail(); } ``` 看完這段程式碼,有沒有開始感受到一些 **Code Smell(程式碼壞味道)**? --- ## 1. 這段程式碼有什麼問題? 我自己後來整理,大致可以歸納成三個問題。 ### 1.1 巢狀結構越長越深 最明顯的就是大量的: ```text if └── if └── if └── if ``` 一開始可能只有: ```csharp if (evt.Rule == CheckInRule.General) { } else if (evt.Rule == CheckInRule.Continuous) { } ``` 但商業需求不會停在這裡。 之後可能開始出現: * 是否已經簽到 * 是否為特殊日期 * 是否連續簽到 * 是否符合特定活動條件 * 是否有對應獎勵 * 是否需要額外獎勵 最後每增加一條規則,都得思考: > **這個新的判斷到底應該塞在哪一層?** 而且真正麻煩的不是把程式碼寫進去。 而是改完之後,很難確定其他分支有沒有一起受到影響。 --- ### 1.2 商業邏輯與資料庫操作高度耦合 另一個問題是,商業規則裡直接穿插: ```csharp db.CheckInRecords.Where(...) db.RewardRecords.Add(...) db.SaveChanges() ``` 例如我真正想驗證的規則可能只是: > 會員連續簽到第 3 天,應該取得第 3 天的獎勵。 理論上這是一條很單純的商業規則。 但如果判斷規則和資料庫操作全部黏在一起,測試時可能得先準備: * Member * CheckInEvent * CheckInRecord * Reward * DbContext * 測試資料 只是想驗證: ```text 第 3 天 → 第 3 天獎勵 ``` 卻得把一大堆不相關的東西一起準備好。 --- ### 1.3 Super Method:一個方法負責太多事情 原本的 `ProcessCheckIn()` 同時負責: * 查詢資料 * 判斷使用哪種簽到規則 * 執行簽到規則 * 計算獎勵 * 建立獎勵紀錄 * 寫入資料庫 * `SaveChanges()` 也就是說,一個方法同時知道: > **資料從哪裡來、規則怎麼判斷、獎勵怎麼算、資料又要怎麼存回去。** --- ## 2. 真正麻煩的是「以後還要繼續改」 如果這段程式從今天開始永遠不會修改,其實問題可能也沒那麼嚴重。 偏偏商業系統通常不是這樣。 今天可能要新增: ```text 特殊日期簽到 ``` 下個月可能又變成: ```text 連續簽到 + 特殊日期 ``` 之後又可能加入: ```text VIP 簽到規則 ``` 每新增一個商業規則,都繼續修改同一個大型方法。 風險也跟著增加。 更麻煩的是: > **新增完規則之後,我要怎麼證明原本的商業邏輯還是正確的?** 這也是我當時真正想處理的問題。 --- # 3. 這次使用 Strategy Pattern 重構 這次最後選擇使用 **Strategy Pattern**。 但目的並不是單純把 `if-else` 變少。 如果只是把程式碼從: ```csharp if (...) { // 100 行 } ``` 搬到: ```csharp class SomeStrategy { // 100 行 } ``` 其實沒有解決多少問題。 這次真正想做的是: > **把不同職責拆開,讓不同的商業規則可以獨立變動、獨立測試。** 先定義所有簽到策略共同遵守的介面: ```csharp public interface ICheckInStrategy { bool CanExecute(CheckInContext context); Result<List<RewardRecord>> Execute(CheckInContext context); } ``` 接著把不同的簽到規則拆成不同 Strategy: ```text ICheckInStrategy │ ├── GeneralCheckInStrategy ├── ContinuousCheckInStrategy ├── SpecialDateCheckInStrategy └── CombinedCheckInStrategy ``` 例如連續簽到: ```csharp public class ContinuousCheckInStrategy : ICheckInStrategy { public bool CanExecute(CheckInContext context) { return context.Event.Rule == CheckInRule.Continuous && context.ContinuousDays > 0; } public Result<List<RewardRecord>> Execute(CheckInContext context) { var reward = context.Event.Rewards .FirstOrDefault(x => x.Day == context.ContinuousDays); if (reward == null) { return Result<List<RewardRecord>> .Fail("今天沒有對應的獎勵設定"); } var record = new RewardRecord( context.Member.Id, reward); return Result<List<RewardRecord>> .Success(new List<RewardRecord> { record }); } } ``` 這時候 `ContinuousCheckInStrategy` 不需要知道: * 一般簽到怎麼判斷 * 特殊日期怎麼判斷 * 其他 Strategy 怎麼運作 * 資料最後怎麼寫進 DB 它只負責一件事情: > **連續簽到的規則。** --- ## 4. 選擇 Strategy 與執行 Strategy 也拆開 接下來還有另一個職責: > **現在到底該使用哪個 Strategy?** 這件事情也不應該丟回原本的大型方法裡。 可以交給 Factory: ```csharp public class CheckInStrategyFactory { private readonly IEnumerable<ICheckInStrategy> _strategies; public CheckInStrategyFactory( IEnumerable<ICheckInStrategy> strategies) { _strategies = strategies; } public ICheckInStrategy GetApplicableStrategy( CheckInContext context) { return _strategies.FirstOrDefault( strategy => strategy.CanExecute(context)); } } ``` 最後 Processor 就只剩下: ```csharp public Result<List<RewardRecord>> Execute( CheckInContext context) { var strategy = _factory.GetApplicableStrategy(context); if (strategy == null) { return Result<List<RewardRecord>> .Fail("目前沒有可執行的簽到規則"); } return strategy.Execute(context); } ``` 整個流程變成: ```text CheckInContext │ ▼ Strategy Factory │ │ 選擇 ▼ ICheckInStrategy │ │ Execute ▼ Reward Result ``` 原本那個方法不再需要知道所有商業細節。 --- # 5. 最大的差異:現在可以分開測試 我自己覺得這次重構最大的收穫,其實不是程式碼變漂亮。 而是: > **測試邊界終於清楚了。** 例如我要驗證: > 會員連續簽到第 3 天,應該拿到第 3 天的獎勵。 現在可以直接建立 Context: ```csharp var context = new CheckInContext { ContinuousDays = 3, Member = member, Event = checkInEvent }; var result = strategy.Execute(context); ``` 接著驗證: ```csharp Assert.True(result.IsSuccess); Assert.Equal(expectedReward, result.Value.First().Reward); ``` 我要測的是: ```text ContinuousCheckInStrategy ``` 那就只測它。 不需要把其他簽到規則一起帶進來。 --- ## 連「使用哪個 Strategy」都可以測 Strategy 本身可以測試。 Strategy 的選擇也可以測試。 例如: ```csharp var strategy = factory.GetApplicableStrategy(context); Assert.IsType<ContinuousCheckInStrategy>(strategy); ``` 所以測試可以拆成兩個不同問題: ```text 問題一: 這個情境應該選哪個 Strategy? ↓ 問題二: 這個 Strategy 執行後的結果正不正確? ``` 這比原本直接測一整個 `ProcessCheckIn()` 清楚很多。 當測試失敗時,也更容易知道問題到底發生在哪裡。 --- # 6. Before / After 最後回頭看一次差異。 | | Before | After | | ----------- | --------- | ------------------- | | 商業規則 | 全部塞在同一個方法 | 每個 Strategy 負責自己的規則 | | 新增規則 | 修改原本大型方法 | 新增 Strategy | | 資料取得 | 與規則混在一起 | 先整理成 Context | | Strategy 選擇 | 混在商業流程 | Factory 負責 | | Strategy 執行 | 混在商業流程 | 各自 Execute | | 單元測試 | 很難只測一條規則 | Strategy 可以獨立測試 | | 選擇邏輯 | 很難單獨驗證 | Factory 也可以獨立測試 | 所以這次 Strategy Pattern 真正幫我的,不是: > **把程式碼變少。** 事實上重構之後,類別數量反而更多。 它真正做到的是: > **把原本糾纏在一起的責任拆開。** --- # 7. 心得 一開始接到這個需求,要修改這段商業邏輯的時候,其實很頭大。 程式不是看不懂。 而是: 看得懂它現在在做什麼,卻不知道新的需求到底應該從哪裡下手。 當時我跟 Claude 討論這段程式的問題,它建議我改用 Strategy Pattern 重構。 我才開始認真去想兩件事: 在這次的情境下,Strategy Pattern 到底帶來什麼好處? 以及,這次真的值得花時間 Refactor 嗎? 我知道用 AI 改很快,但大改就代表整段邏輯都要重新測過。 不過反過來想,硬著頭皮在原本的程式碼上加規則,其實一樣要重新測——因為新的判斷會散落在各層 if 裡,幾乎每個分支都會被動到。 既然兩邊的測試成本差不多,那不如趁這次把結構一起處理掉。 後來實際做完,我覺得效益比想像中大。不只是程式碼變得好維護,而是我對現有功能的掌握度變高了——因為每條規則都測得到。 也因為測試邊界變清楚,這次我盡量用 BDD(Behavior-Driven Development)的方式,先把情境列出來,再回頭補測試。 老實說,請 AI 幫忙列情境這件事,我覺得是輔助性質。它確實列得出來,有些情境看起來也合理,但我不太確定有沒有漏掉什麼——雖然說,它列的可能還是比我自己硬想的更多、更嚴謹一點。