iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
自我挑戰組

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

Day 3|S(單一職責) 一個類別,請只給它一個變心的理由

  • 分享至 

  • xImage
  •  

在講故事之前,在上篇結尾有提到: SOLID,那什麼是 SOLID 呢?

SOLID 是物件導向設計原則的五個英文縮寫,五個原則,每個縮寫都是一個單字,說明軟體開發風格
亦代表,就是大師走過的坑,需要好好學習呀~

由軟體工程大師 Robert C. Martin(Uncle Bob)提出:

S — Single Responsibility Principle (單一職責原則)
O — Open-Closed Principle (開放封閉原則)
L — Liskov Substitution Principle (里氏替換原則)
I — Interface Segregation Principle (介面隔離原則)
D — Dependency Inversion Principle (依賴反轉原則)

第一次看到這個的時候頭就痛了(?
而當這些原則能妥善應用在專案時,能大幅提升軟體的可維護性和擴展性,讓我們寫出更專業的程式碼

如同伊果大大說道

單一職責原則就是很好的檢驗方式,讓我們可以用來判斷這個類別是否足夠健康

那我們來看第一個 S 單一職責原則 Single Responsibility Principle 的定義吧(簡稱 SRP )

SRP 啊,通常在界定上最為模糊,因為針對定義 Uncle Bob 就解釋許多次,也經過多年的演進

後續在 Uncle Bob 的書籍 CleanArchitecture 說明

A module should have one, and only one, reason to change.
「一個模組應該只有一個被改變的原因」

我也引用Fred聊聊SOLID設計原則的說明

  1. 「一個組件會發生變更的原因,應該來自同一個業務關聯方、同一群會對組件提出業務需求的人」
  2. 「決定職責為何的是『人』,而非其他組件或系統」
  3. 「讓一個軟體組件,因為業務的耦合而造成的維護可能性問題降低」

「將不同意圖、不同使用情境、不同需求、不同修改時機的功能劃分為各自獨立的『職責』」

誰提需求 → 這群需求構成一個職責 → 一個職責對應一個 class → 職責分乾淨了,修改才會局部化,風險才會降低

好我們回到故事

阿柴接手了

阿柴翻開小黃留下的程式碼,想先摸清楚這套系統的底細。小黃當初把「算金額、印收據、傳 Line 通知老闆、存進資料庫」四件事,全部塞進同一支 OrderService

跑是跑得動,阿柴看了看,心想「古人有說,能跑的程式碼就先別動」,就先去忙別的事

好景不常

兩週後,老闆小黑說:「收據格式可以換一下嗎?我想加上店名跟日期」

阿柴:「好,沒問題小改而已」

改完之後,Line 通知突然壞掉了

阿柴:「???」

阿柴打開 log 一看,是印收據那段程式碼要抓店家資訊,結果阿柴發現 store 是 null,一取用 store.Name 就直接噴出例外,整個 method 卡在那裡中斷

這時候先別緊張,深呼吸

我們來看看那個 class 長什麼樣

public class OrderService
{
    public async Task ProcessOrder(Order order)
    {
        // A.計算金額
        decimal total = order.Items.Sum(i => i.Price * i.Quantity);

        // B.印出收據
        var store = GetStoreInfo();
        Console.WriteLine($"=== {store.Name} 柴咖啡收據 ===");  // 老闆要求加店名,但 store 可能是 null,這裡直接噴 NullReferenceException
        Console.WriteLine($"日期:{order.Date}");               // 老闆要求加日期
        Console.WriteLine($"小計:{total}");

        // C.傳 Line 通知老闆 ← 因為上面已經炸了,這行永遠不會被執行到
        var client = new HttpClient();
        await client.PostAsync("https://notify-api.line.me/...", new StringContent($"新訂單!金額:{total}"));

        // D.存進資料庫
        var db = new AppDbContext();
        db.Orders.Add(order);
        db.SaveChanges();
    }
}

這時候也幸好有出現 Error,我們才知道這是個「不健康」的類別

一個 method,四件事:計算、印收據、通知、存資料庫

看起來很方便,問題是——這四件事,各自都有各自的「改變理由」:

  • 老闆手癢想打促銷,折扣算法要能常常調 → 改 A
  • 老闆要換收據格式,想加店名跟日期 → 改 B
  • 老闆想改通知規則,內容要多帶訂單明細 → 改 C
  • 老闆想多存訂單備註欄位,或哪天想換資料庫廠商 → 改 D

現在店裡就老闆跟員工兩個人,這四件事目前都是老闆一個人在喊,嚴格說,還稱不上「四個不同的業務關聯方」

但即使是同一個人,這四件事「多久變一次」「牽涉的風險」也不同

收據格式可能一年才改一次

促銷折扣可能一週要調好幾次

通知規則單純是老闆自己的使用習慣,想改就改

資料庫欄位或廠商通常是系統要擴充時才會動,頻率最低,但一旦要動,風險也最高

不同的變動頻率、不同的風險等級,本身就是分開處理的理由

每一次改動,都有可能在對其他功能埋地雷

SRP 說什麼

一個 class,應該只有一個改變的理由

換句話說,一個 class 只負責一個職責,當需求變動時,只有跟它有關的那個理由會讓它需要改


拆開來可以長這樣

// 只負責計算
public class OrderCalculator
{
    public decimal Calculate(Order order)
        => order.Items.Sum(i => i.Price * i.Quantity);
}

// 只負責印收據
public class ReceiptPrinter
{
    public void Print(Order order, decimal total)
    {
        PrintHeader(order);
        PrintItems(order);
        PrintTotal(total);
    }

    private void PrintHeader(Order order)
    {
        var store = GetStoreInfo();
        Console.WriteLine($"=== {store.Name} 柴咖啡收據 ===");
        Console.WriteLine($"日期:{order.Date}");
    }

    private void PrintItems(Order order)
    {
        foreach (var item in order.Items)
        {
            Console.WriteLine($" {item.Price * item.Quantity}");
        }
    }

    private void PrintTotal(decimal total)
    {
        Console.WriteLine($"小計:{total}");
    }
}

// 只負責通知
public class LineNotifier
{
    public async Task Notify(decimal total)
    {
        var client = new HttpClient();
        await client.PostAsync("https://notify-api.line.me/...", new StringContent($"新訂單!金額:{total}"));
    }
}

// 只負責存資料
public class OrderRepository
{
    public void Save(Order order)
    {
        var db = new AppDbContext();
        db.Orders.Add(order);
        db.SaveChanges();
    }
}

// OrderService 重構後的模樣
public class OrderService
{
    private readonly OrderCalculator _calculator = new();
    private readonly ReceiptPrinter _printer = new();
    private readonly LineNotifier _notifier = new();
    private readonly OrderRepository _repository = new();

    public async Task ProcessOrder(Order order)
    {
        // 1. 計算
        var total = _calculator.Calculate(order);

        // 2. 列印
        _printer.Print(order, total);

        // 3. 非同步發送通知
        await _notifier.Notify(total);

        // 4. 存檔
        _repository.Save(order);
    }
}

現在老闆要換收據格式,只動 ReceiptPrinter,Line 通知不會受影響,資料庫也沒事

用 SRP 當照妖鏡

下次不管是接手別人留下的程式碼,還是叫 AI 幫你生程式碼,都可以問自己:

  • 這個 class 有幾個改變的理由? 超過一個,或許就該拆

    例如前面的 OrderService,收據格式、通知規則、促銷折扣、資料庫欄位,四件事各自的變動頻率、風險都不一樣

    混在同一個 class 裡,任何一個變動都可能拖累其他功能,這可能就是個訊號

    可以拆成 計算列印通知存資料

  • 如果需求 A 改了,會不會意外影響功能 B? 會的話,就是職責混在一起了

  • 這個 class 的名字能說清楚它做什麼嗎?OrderService 但做了四件事,這樣就不健康


SRP 的邊界在哪

SRP 不是說「一個 class 只能有一個 method」,這樣會拆得很辛苦,也是矯枉過正

判斷標準是:這個 class 裡的所有邏輯,是不是為了同一個目的而存在?

ReceiptPrinter 裡面可以有多個 method: PrintHeader()PrintItems()PrintTotal(),它們都為了「印收據」這一件事服務,這樣是沒問題的

怕的是一個 class 同時在做「印收據」和「通知老闆」,因為這兩件事不是同一個理由,就會是個警訊

擁抱 SRP 的優勢

Katsuobushi 大大曾列舉 SRP 以下的優點

  1. 降低相依性、耦合性

    減少程式碼之間的依賴,讓該模組的變更將會來自同一個業務需求,因業務耦合造成的維護問題減少,進而也減少需求更改而影響到另一個業務需求的情況

  2. 提高內聚力

    程式中每個部分都與自己實作的功能相關,提高內聚力,提高可重用性

  3. 提高可讀性、可維護性及降低複雜性

    一個模組只包含與自身相關的邏輯,降低程式複雜性,提高可讀性、可維護性

阿柴也透過 SRP 的觀念,依序將 code 重構成安全的樣子,可喜可賀,可口可樂
https://ithelp.ithome.com.tw/upload/images/20260831/20183470pfRlbLHySy.jpg


下一篇,我們繼續看 SOLID 的第二條:O 開放封閉原則

柴咖啡要開始加新口味了

延伸閱讀文章:
軟體架構設計原則 1 - SRP 單一職責原則

The Single Responsibility Principle

Single-responsibility principle

再談物件導向設計原則: 單一職責原則,定義、解析與實踐

菜雞與物件導向 (10): 單一職責原則

菜雞與物件導向 (8): 內聚、耦合

單一職責原則

Fred聊聊SOLID設計原則


上一篇
Day 2|這個時代還要學 Design Pattern 嗎
下一篇
Day 4|O(開放封閉) 柴咖啡新品銷售,如何擴充菜單才不會搞砸?
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言