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 自然的體現,每個層次應該只包含屬於自身的職責,而不應越層操作:
- 業務邏輯層(如
OrderService、DiscountCalculator):只放純粹的領域規則和業務判斷,不碰資料庫查詢也不管 HTTP 格式。 - 呈現層(如
HtmlFormatter、PdfRenderer、API Controllers):只決定資料如何被呈現或傳輸,不含業務規則。 - 基礎設施層(如
SqlRepository、EmailNotifier、S3FileStorages):只處理與外部系統(資料庫、Email 服務、雲端儲存)的溝通,不含業務邏輯。
常見誤解與邊界情況
SRP 代表每個類別只能有一個方法
這是很常見的誤解,一個類別可以有很多個方法,只要它們全部服務於同一個職責;例如一個 HtmlReportFormatter 可以同時提供 FormatOrders()、FormatProducts()、AddPageHeader()、AddPageFooter() 等多個方法,因為它們全都屬於「HTML 格式化」這個單一職責。
SRP 的粒度越細越好
過度分割同樣是問題,如果把一個 UserService 拆成 UserCreator、UserUpdater、UserDeleter、UserFinder 四個類別,但這四個操作在業務上始終是一體的(它們服務於同一個 actor,且相互依賴),這樣的分割只會讓程式碼更難追蹤,而非更清晰。
SRP 的粒度應以「actor(利害關係人)」為基準,而非以「方法數量」為基準。
邊界情況:協調者類別
有人可能會問:SendWeeklyReportUseCase 依賴了三個類別,它算不算違反 SRP?
答案是不算,這個類別的職責是「協調這個使用場景的執行流程」,它本身並不包含業務邏輯、格式化邏輯或基礎設施邏輯;它會改變的唯一理由是「這個業務流程的步驟改變了」,這符合 SRP 的定義。
重點回顧
- SRP 的核心概念是 actor(利害關係人),不是方法數量。
- 一個類別應只對一個 actor 負責,也就是只有一個改變的理由。
- 違反 SRP 的典型症狀:類別難以命名(因為它做太多事)、單元測試需要初始化大量無關的依賴、一個需求變更需要修改看似無關的程式碼。
- SRP 與分層架構天然對齊:每一層只做屬於那一層的事。
- 過度拆分與拆分不足同樣是問題,判斷標準是職責邊界,而非程式碼行數。