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 幫忙列情境這件事,我覺得是輔助性質。它確實列得出來,有些情境看起來也合理,但我不太確定有沒有漏掉什麼——雖然說,它列的可能還是比我自己硬想的更多、更嚴謹一點。
點擊複製文章連結