iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 4

Day 4|O(開放封閉) 柴咖啡新品銷售,如何擴充菜單才不會搞砸?

  • 分享至 

  • xImage
  •  

上一篇結尾提到,柴咖啡要開始加新口味了~~

阿柴用 SRP 把訂單服務細拆成訂單計算、發票列印、LINE通知、存入訂單四個各司其職的 class,本來以為可以鬆一口氣

沒想到,麻煩換了個位置出現

老闆的新口味

老闆小黑興沖沖地跑來:「最近抹茶紅了,我們也上一款抹茶拿鐵吧!」

阿柴:「沒問題,飲料種類加個 case 就好」

打開 OrderCalculator,阿柴很快加了一個新分支,上線

兩天後,小黑又來了:「美式最近賣不好,加個常態九折試試?」

阿柴:「好」,又加了一個分支

又過一週,小黑丟了兩個需求

  1. 抹茶拿鐵加購燕麥奶折 5 元
  2. 全品項滿 200 折 20,衝一下客單價

抹茶拿鐵那段好處理,阿柴很快改完

但「滿 200 折 20」是整張訂單層級的判斷,需要先知道這張訂單「折扣前」的原始金額是多少

阿柴在 Calculate() 裡加了一個共用變數,把四個 case 都改成先算出 rawPrice(折扣前金額)再各自處理,反正都要動這個 method,一次改完

改完自己測了一下抹茶拿鐵跟滿額折扣,沒問題,就上線了

隔天,客訴進來:「老闆,我點美式,價格是不是錯了?」

https://ithelp.ithome.com.tw/upload/images/20260901/2018347058i46c2Ln0.jpg

阿柴打開 log 一看,原來把四個 case 統一改成用 rawPrice 的時候,美式那行忘記把 * 0.9m 加回去,變成直接收原價

我們來看看那段程式碼長什麼樣

public class OrderCalculator
{
    public decimal Calculate(Order order)
    {
        decimal total = 0;
        decimal rawTotal = 0; // 判斷「滿 200 折 20」用的原始金額

        foreach (var item in order.Items)
        {
            decimal rawPrice = item.Price * item.Quantity;
            rawTotal += rawPrice;

            switch (item.DrinkType)
            {
                case DrinkType.Latte:
                    total += rawPrice;
                    break;

                case DrinkType.Americano:
                    total += rawPrice; // 原本是 rawPrice * 0.9m,改成共用 rawPrice 時,忘記把九折乘回去
                    break;

                // 新增口味都要擠進這裡
                case DrinkType.MatchaLatte:
                    total += rawPrice - (item.OatMilkAddOn ? 5 : 0);
                    break;
            }
        }

        if (rawTotal >= 200) total -= 20;
        return total;
    }
}

問題不是「加了一個 case」,隱患是只要一碰這個 method,其他跟這次需求無關的邏輯,也得一起被牽動

阿柴原先沒打算動美式,他只是想幫「滿 200 折 20」這個新規則,從迴圈裡多算一個原始金額的加總

但因為三種飲料的邏輯全部寫在同一個 method 裡,這個小小的重構動作還是得把四個 case 一起重寫過一輪,重寫的過程,美式的九折就被漏掉

好 我們回到 SOLID 的開放封閉原則 簡稱 OCP

OCP 是什麼

由 Bertrand Meyer 在 1988 年提出,後來 Uncle Bob 在《Clean Architecture》裡進一步闡述:

Software entities (classes, modules, functions) should be open for extension, but closed for modification.
「軟體實體(類別、模組、函式)應該對擴充開放,對修改封閉」

By 深入淺出設計模式
類別應該歡迎擴展,但拒絕修改

白話一點:新需求進來,應該用「新增程式碼」去滿足它,而不是回頭「修改既有程式碼」
或者說:大多數情況下,一個設計良好的系統應該在不需要修改的前提之下就可以輕易被擴展

拆開來可以長這樣

把「每一種飲料怎麼算錢」抽出來用 Interface 去做管理,飲料自己實作自己的算法,OrderCalculator 只負責找到對的算法、呼叫它,不用知道細節

public interface IDrinkPricing
{
    bool CanHandle(DrinkType type);
    decimal Calculate(OrderItem item);
}

public class LattePricing : IDrinkPricing
{
    public bool CanHandle(DrinkType type) => type == DrinkType.Latte;
    public decimal Calculate(OrderItem item) => item.Price * item.Quantity;
}

public class AmericanoPricing : IDrinkPricing
{
    public bool CanHandle(DrinkType type) => type == DrinkType.Americano;
    public decimal Calculate(OrderItem item) => item.Price * item.Quantity * 0.9m;
}

// 新口味上架,只要新增一個 class,不用碰美式、拿鐵的 code
public class MatchaLattePricing : IDrinkPricing
{
    public bool CanHandle(DrinkType type) => type == DrinkType.MatchaLatte;

    public decimal Calculate(OrderItem item)
    {
        var total = item.Price * item.Quantity;
        return item.OatMilkAddOn ? total - 5 : total;
    }
}

public class OrderCalculator
{
    private readonly IEnumerable<IDrinkPricing> _pricingRules;

    public OrderCalculator(IEnumerable<IDrinkPricing> pricingRules)
    {
        _pricingRules = pricingRules;
    }

    public decimal Calculate(Order order)
    {
        decimal total = 0;
        decimal rawTotal = 0;

        foreach (var item in order.Items)
        {
            rawTotal += item.Price * item.Quantity; // 滿額門檻只看原始金額,跟飲料折扣邏輯無關
            var rule = _pricingRules.First(r => r.CanHandle(item.DrinkType));
            total += rule.Calculate(item);
        }

        if (rawTotal >= 200) total -= 20;
        return total;
    }
}

下次美式想改促銷方式,只動 AmericanoPricing,抹茶拿鐵、拿鐵完全不會被牽連

OCP 的範圍很具體:只要需求是「多一種飲料的算法」,OrderCalculator.Calculate() 這個入口方法就不會再被打開,只要新增一個 class 去實作 IDrinkPricing.Calculate()

達成「新增一段新的」,不用「修改既有的」的方式

用 OCP 當照妖鏡

下次在 review 時,可以反問自己:

  • 這次的新需求,是「新增」還是「修改」? 如果每次都要回去改同一個 method,那個 method 可能就是 OCP 的破口

  • if-else / switch 是不是一直在同一個地方長大? 像前面的 OrderCalculator,每上一款新飲料就多一個 case

  • 改 A 需求,會不會被迫連帶動到 B? 如果會,代表這段邏輯的隔離度不足,該考慮拆分或抽象化

OCP 的邊界在哪

OCP 不是叫你一開始就把每個東西都包成介面,預留一堆「以後可能會變」的擴充點
(畢竟當我全部都想完的時候,可能天都黑了...)

判斷標準可以是:這裡「已經」變過幾次,而不是「可能」會變

如果一款飲料上架後半年都沒改過算價方式,先不用急著抽象化

但像本篇的促銷邏輯,短短兩週改了三次,這種「已經在變、有可能還會變」的地方,或許是 OCP 該出手的地方


下一篇,我們繼續看 SOLID 的第三條:L——里氏替換原則

延伸閱讀

架構整潔之道

[Design Pattern]-開放封閉原則和前端元件開發-(OCP principle in Vue component design)

[CK Patt 設計模式#4] 開放封閉原則(OCP)

菜雞與物件導向 (11): 開放封閉原則


上一篇
Day 3|S(單一職責) 一個類別,請只給它一個變心的理由
下一篇
Day 5|L (里氏替換) 柴咖啡促銷,買一送一遇上無法兌現的承諾
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言