Robert C. Martin 對這個原則的定義是:A class should have only one reason to change(一個類別應該只有一個改變的理由)。

乍看之下這句話的意思相當直觀:一個類別只做一件事,但「改變的理由」這個措辭或許比表面上更深刻,也是 SRP 常被誤解的地方。


與 OOP 的關係:封裝(Encapsulation)

封裝是 OOP 的第一個基石,它的目的是將資料與操作資料的行為包裝在一起,並對外隱藏實作細節;這讓類別可以像一個「黑盒子」,使用者只需要知道「它能做什麼」,不需要知道「它是怎麼做的」。

然而,封裝只解決了「如何包裝」的問題,沒有回答「應該把哪些東西包在一起」;一個把所有功能都塞進同一個類別的設計,在語法上是合法的封裝,但在設計上卻是災難性的。

SRP 為封裝提供了邊界判斷的標準:同一個職責(responsibility)的程式碼才應該被封裝在同一個類別裡;換句話說,類別的邊界不應由技術上的便利性決定(這幾個 method 放在一起比較方便),而應由職責的邊界來決定(這幾個 method 服務於同一個目的、同一個改變來源)。

也就是說,SRP 是封裝的昇華:不只把資料藏起來,更要讓「職責」成為劃分類別邊界的判斷依據。


「改變的理由」的含義

Uncle Bob 在後來的詮釋中進一步說明:一個模組應該對一個且只對一個 actor(行為者) 負責。

這裡的 actor 不是指使用者介面上的角色,而是指會要求這個模組發生改變的利害關係人(stakeholder),可能是一個部門、一個業務單位、一個外部系統,或任何一個有能力提出需求變更的人或團隊。

用一個具體例子來說明:假設有一個 Employee 類別,它提供三個方法:

  • CalculatePay():計算薪資,財務部門關心這個邏輯。
  • ReportHours():回報工時,HR 部門關心這個邏輯。
  • Save():儲存員工資料,DBA 或資料庫團隊關心這個邏輯。

三個方法,三個不同的 actor;財務部門可能要求調整薪資計算的演算法,HR 部門可能要求修改工時的統計方式,資料庫團隊可能要求更換 ORM 框架或調整儲存格式;這三類需求彼此獨立,但因為它們全都落在同一個 Employee 類別裡,任何一方的修改都有可能意外影響其他兩方,即使改動本身只涉及其中一個方法。

這就是「有多個改變的理由」的意涵:一個類別因為服務多個 actor,所以有多個獨立的、互不相關的原因可能促使它發生改變。


程式碼範例

違反 SRP:三種職責擠在一個類別

以下是一個很常見的反模式,在實際專案中幾乎隨處可見:

 1public class ReportService
 2{
 3    private readonly string _connectionString;
 4
 5    public ReportService(string connectionString)
 6    {
 7        _connectionString = connectionString;
 8    }
 9
10    // 職責一:取得業務資料(屬於業務邏輯層)
11    // actor:業務分析師、後端工程師
12    public List<Order> GetOrders()
13    {
14        using var conn = new SqlConnection(_connectionString);
15        // 查詢並回傳訂單清單...
16        return new List<Order>();
17    }
18
19    // 職責二:格式化報表(屬於呈現層)
20    // actor:前端工程師、設計師
21    public string FormatAsHtml(List<Order> orders)
22    {
23        var sb = new StringBuilder();
24        sb.Append("<table><thead><tr><th>ID</th><th>Total</th></tr></thead><tbody>");
25        foreach (var o in orders)
26            sb.Append($"<tr><td>{o.Id}</td><td>{o.Total:C}</td></tr>");
27        sb.Append("</tbody></table>");
28        return sb.ToString();
29    }
30
31    // 職責三:傳送通知(屬於基礎設施層)
32    // actor:維運團隊、產品經理
33    public void SendByEmail(string recipient, string content)
34    {
35        var client = new SmtpClient("smtp.example.com");
36        client.Send("[email protected]", recipient, "Weekly Report", content);
37    }
38}

這個設計的問題不只是「感覺不太對」,而是會帶來非常具體的麻煩:

  • 若公司改用 SendGrid 取代 SMTP,就必須修改 ReportService 類別,即使這個改動與「查詢訂單」或「格式化報表」無關,卻要重新測試整個類別。
  • 若前端想要 PDF 而非 HTML,同樣需要修改這個類別,同樣可能誤觸資料查詢或郵件發送的邏輯。
  • 若想為這個類別撰寫單元測試,必須同時準備資料庫連線、HTML 格式的驗證邏輯、以及 SMTP 伺服器的 Mock,三件事缺一不可,但它們根本是不同的關注點。

遵守 SRP:按職責拆分為獨立類別

改寫後的設計把三個職責分別交給三個專職的類別,再透過一個協調者(Use Case)組合它們:

 1// 職責一:業務資料查詢
 2// 只知道「如何從資料庫取得訂單」,不管報表長什麼樣、也不管怎麼發出去
 3public class OrderRepository
 4{
 5    private readonly string _connectionString;
 6
 7    public OrderRepository(string connectionString)
 8        => _connectionString = connectionString;
 9
10    public List<Order> GetAll()
11    {
12        using var conn = new SqlConnection(_connectionString);
13        // 查詢邏輯...
14        return new List<Order>();
15    }
16
17    public List<Order> GetByDateRange(DateTime from, DateTime to)
18    {
19        using var conn = new SqlConnection(_connectionString);
20        // 依日期範圍查詢...
21        return new List<Order>();
22    }
23}
24
25// 職責二:報表格式化
26// 只知道「如何將訂單資料轉換成 HTML 字串」,不管資料從哪來、也不管怎麼發出去
27public class HtmlReportFormatter
28{
29    public string Format(List<Order> orders, string title = "Order Report")
30    {
31        var sb = new StringBuilder();
32        sb.AppendLine($"<h1>{title}</h1>");
33        sb.AppendLine("<table>");
34        sb.AppendLine("<thead><tr><th>訂單 ID</th><th>金額</th><th>日期</th></tr></thead>");
35        sb.AppendLine("<tbody>");
36        foreach (var order in orders)
37            sb.AppendLine($"<tr><td>{order.Id}</td><td>{order.Total:C}</td><td>{order.CreatedAt:d}</td></tr>");
38        sb.AppendLine("</tbody></table>");
39        return sb.ToString();
40    }
41}
42
43// 職責三:Email 通知傳送
44// 只知道「如何透過 SMTP 發送一封 Email」,不管內容是什麼、也不管資料從哪來
45public class EmailNotifier
46{
47    private readonly string _smtpHost;
48    private readonly string _from;
49
50    public EmailNotifier(string smtpHost, string from)
51    {
52        _smtpHost = smtpHost;
53        _from = from;
54    }
55
56    public void Send(string to, string subject, string body)
57    {
58        var client = new SmtpClient(_smtpHost);
59        client.Send(_from, to, subject, body);
60    }
61}
62
63// 協調者(Use Case):負責編排流程,自身不包含任何業務邏輯或技術細節
64// 它只是把各個職責「串起來」,描述「這個使用場景應該做什麼事、按什麼順序」
65public class SendWeeklyReportUseCase
66{
67    private readonly OrderRepository _repo;
68    private readonly HtmlReportFormatter _formatter;
69    private readonly EmailNotifier _notifier;
70
71    public SendWeeklyReportUseCase(
72        OrderRepository repo,
73        HtmlReportFormatter formatter,
74        EmailNotifier notifier)
75    {
76        _repo = repo;
77        _formatter = formatter;
78        _notifier = notifier;
79    }
80
81    public void Execute(string recipientEmail)
82    {
83        // 步驟一:取得本週訂單
84        var from = DateTime.Today.AddDays(-7);
85        var orders = _repo.GetByDateRange(from, DateTime.Today);
86
87        // 步驟二:格式化成 HTML 報表
88        var html = _formatter.Format(orders, $"週報({from:d} ~ {DateTime.Today:d})");
89
90        // 步驟三:寄送給收件人
91        _notifier.Send(recipientEmail, "每週訂單報表", html);
92    }
93}

拆分之後,每個類別都只有一個改變的理由:

  • OrderRepository 只會因為「資料庫查詢邏輯改變」而修改。
  • HtmlReportFormatter 只會因為「報表的 HTML 結構改變」而修改。
  • EmailNotifier 只會因為「Email 發送機制改變(如換用 SendGrid)」而修改。
  • SendWeeklyReportUseCase 只會因為「這個業務場景的流程改變」而修改。

判斷技巧

使用「以及」測試

如果不確定一個類別是否違反 SRP,可以用這個句型測試:「這個類別負責 ___,以及 ___,以及 ___。」如果「以及」後面還有內容,幾乎可以確定這個類別承擔了太多職責,需要考慮拆分。

為什麼這個類別會改變?

列出這個類別可能改變的所有原因,如果列出了兩個以上,且這些原因彼此無關(來自不同的 actor),就是拆分的訊號。

分層架構對照

良好的分層設計是 SRP 自然的體現,每個層次應該只包含屬於自身的職責,而不應越層操作:

  • 業務邏輯層(如 OrderServiceDiscountCalculator):只放純粹的領域規則和業務判斷,不碰資料庫查詢也不管 HTTP 格式。
  • 呈現層(如 HtmlFormatterPdfRenderer、API Controllers):只決定資料如何被呈現或傳輸,不含業務規則。
  • 基礎設施層(如 SqlRepositoryEmailNotifierS3FileStorages):只處理與外部系統(資料庫、Email 服務、雲端儲存)的溝通,不含業務邏輯。

常見誤解與邊界情況

SRP 代表每個類別只能有一個方法

這是很常見的誤解,一個類別可以有很多個方法,只要它們全部服務於同一個職責;例如一個 HtmlReportFormatter 可以同時提供 FormatOrders()FormatProducts()AddPageHeader()AddPageFooter() 等多個方法,因為它們全都屬於「HTML 格式化」這個單一職責。

SRP 的粒度越細越好

過度分割同樣是問題,如果把一個 UserService 拆成 UserCreatorUserUpdaterUserDeleterUserFinder 四個類別,但這四個操作在業務上始終是一體的(它們服務於同一個 actor,且相互依賴),這樣的分割只會讓程式碼更難追蹤,而非更清晰。

SRP 的粒度應以「actor(利害關係人)」為基準,而非以「方法數量」為基準。

邊界情況:協調者類別

有人可能會問:SendWeeklyReportUseCase 依賴了三個類別,它算不算違反 SRP?

答案是不算,這個類別的職責是「協調這個使用場景的執行流程」,它本身並不包含業務邏輯、格式化邏輯或基礎設施邏輯;它會改變的唯一理由是「這個業務流程的步驟改變了」,這符合 SRP 的定義。


重點回顧

  • SRP 的核心概念是 actor(利害關係人),不是方法數量。
  • 一個類別應只對一個 actor 負責,也就是只有一個改變的理由。
  • 違反 SRP 的典型症狀:類別難以命名(因為它做太多事)、單元測試需要初始化大量無關的依賴、一個需求變更需要修改看似無關的程式碼。
  • SRP 與分層架構天然對齊:每一層只做屬於那一層的事。
  • 過度拆分與拆分不足同樣是問題,判斷標準是職責邊界,而非程式碼行數。