單一職責原則(Single Responsibility Principle, SRP),一個模組,應該只有一個改動的理由。
這也是 Unix 哲學之一:「做一件事,並把它做好。」
如果你的函式和類別或模組混合了多種職責,「取得資料」、「處理運算」、「輸出結果」,你的程式碼就會開始散發難以忽視的「臭味」。
把抓資料、算資料、呈現資料,徹底分開。
精準地說,這代表應該只有一種業務需求或角色(Actor)的變更,會驅使你修改這個模組。
如果需求來自不同的角色,就代表它們是不同的修改理由。
資料庫管理員 關心 DB 結構調整。
產品經理 關心報表格式與商業邏輯。
系統架構師 關心輸出方式的變更(例如從終端機改為 API)。
這三種理由不應該被綁在同一個模組裡。
如何有效地劃分職責?這裡有幾個實用的切入點:
按「資料流」切分: 取得(Input)→ 轉換(Process)→ 輸出(Output)。
按「變動頻率」切分: 將頻繁變動的業務規則與相對穩定的 I/O 操作分開。
按「邊界」切分: 將純粹的計算邏輯與會產生副作用(Side Effect)的外部互動拆離。
當一個檔案,會因為上述三個不同角色的需求而被迫修改,它就已嚴重違反了設計原則,你的程式碼正在發臭。
// 🔴 臭味:實際場景的程式碼肯定是超級大包,先用簡短範例說明
class ReportService {
async run(db) {
// 理由 1: 資料庫 schema 或查詢語言改變
const rows = await db.query('SELECT * FROM orders');
// 理由 2: 報表格式改變
const text = rows.map(r => `${r.id}:${r.total}`).join('\n');
// 理由 3: 輸出目標改變
console.log(text);
}
}
它無法測試: 你想測試中間那段格式化的邏輯嗎?抱歉,你得先搭一個真的資料庫,然後還得想辦法攔截 console.log
的輸出。為了測試一小段純文字處理,還要搞好一大堆 I/O 問題。
它無法重用: 如果現在有個新需求,要產生同樣的報表,但不是印出來,而是作為 API 的回傳值呢?你怎麼辦?複製貼上整段程式碼,然後只改最後一行?製造出我們前幾天寫過的 Bug 臭味製造機。
它極度脆弱: 任何一個環節的改變,都可能影響到其他環節。改了 SQL 查詢,可能會影響格式化;改了格式化,可能會讓輸出壞掉。
// 🟢 好品味:這是一個由三個獨立、專精的工具組成的工具箱。
// 工具 1: 負責「撈資料」的扳手。它只關心怎麼從資料庫拿東西。
async function fetchOrders(db) {
return db.query('SELECT * FROM orders');
}
// 工具 2: 負責「算資料」的計算機。這是純粹的邏輯,不碰任何 I/O。
function formatOrdersAsText(rows) {
return rows.map(r => `${r.id}:${r.total}`).join('\n');
}
// 工具 3: 負責「輸出」的印表機。它只關心怎麼把文字弄到螢幕上。
function printToConsole(text) {
console.log(text);
}
// 這是一個「工頭」,它不親自幹活,只負責調度工具。
async function runReportWorkflow(db) {
const rows = await fetchOrders(db);
const text = formatOrdersAsText(rows);
printToConsole(text);
}
拆分的好處顯而易見。
可獨立測試: 這樣可以獨立地、輕鬆地測試 formatOrdersAsText
。給它一個假陣列,斷言它回傳的字串。不需要資料庫,不需要 console
。
可自由組合 (Composability): 如果要把報表輸出成 API 回應也會很簡單。
// 重用 fetchOrders 和 formatOrdersAsText 這兩個工具
const rows = await fetchOrders(db);
const text = formatOrdersAsText(rows);
return new Response(text); // 換一個輸出工具即可
職責清晰: 每個函式都有一個極其狹窄、定義良好的職責。看名字就知道它是幹嘛的。修改時,也能肯定改動只會影響它自己的職責範圍。
這是一個從混亂到清晰的實用重構藍圖:
解救「大腦」:最優先將最純粹的計算邏輯(格式化、驗證)抽離成「純函式 Pure Function」。純函式最容易測試。
隔離「邊界」:接著將與外部世界互動的輸入/輸出 (I/O) 操作(讀寫資料庫、呼叫 API)也抽離成獨立的「邊界函式」,將不確定性控制在小範圍內。
建立「工頭」:最後建立一個新的「組合函式」,它不親自工作,只負責調度前面建立的純函式與邊界函式,完成業務流程。原有的巨大類別便可功成身退。
好的設計就是好測試。
在你的程式碼裡留下可以輕易更換零件的插槽,針對不同職責的函式,採用不同測試策略:
純計算函式:進行單元測試 (Unit Test),驗證其邏輯的絕對正確性。
邊界 (I/O) 函式:使用測試替身 (Test Doubles), Stubs (提供假資料) 和 Mocks (驗證呼叫行為),來模擬外部依賴,從而避免緩慢且不穩定的真實 I/O。
組合 (工頭) 函式:進行整合測試 (Integration Test),只驗證各工具函式是否被正確地串連起來,確保它們之間的「契約」有被遵守。
遵守單一職責原則,就是有意識地將流程(Process) 拆解成輸入(Input)、運算(Process)、輸出(Output) 三個獨立部分。
這能讓你的程式碼職責清晰、易於測試、方便重用,從根本上消除臭味,提升程式碼品質。