寫程式不難,難的是半年後回頭看,還敢不敢改自己的程式碼。當一個類別同時算薪水、寫資料庫、寄信,甚至順便幫你煮咖啡,需求一變就會像耳機線一樣打結。這時候,SOLID 原則可以當一張設計地圖。
這篇會用 PHP 範例帶你理解 SOLID 的五個原則:它們各自想解決什麼問題、怎麼拆出比較健康的設計,以及什麼時候不必硬套。讀完後,你應該能看出程式碼「哪裡開始變胖」,也知道該從哪裡下刀。
目錄
SOLID 是什麼?
SOLID 是五個物件導向設計原則英文名稱的首字母:
- S — Single Responsibility Principle(單一職責原則)
- O — Open/Closed Principle(開放封閉原則)
- L — Liskov Substitution Principle(里氏替換原則)
- I — Interface Segregation Principle(介面隔離原則)
- D — Dependency Inversion Principle(依賴反轉原則)
它們不是 PHP 的語法,也不是一份「照著做就永遠正確」的檢查表,而是幫助你降低耦合、控制變動範圍的思考工具。換句話說,SOLID 的重點不是把類別拆到像樂高散落一地,而是讓每個變動有比較清楚的落點。
S:單一職責原則(SRP)
單一職責原則常被簡化成「一個類別只做一件事」。這句話方向沒錯,但還不夠精準。Robert C. Martin 對 SRP 的經典說法是:一個模組應該只有一個「改變的理由」。
這裡的「職責」不是方法數量,而是誰會要求它改變。薪資規則由人資提出修改、資料庫格式由後端提出修改、畫面格式由前端提出修改;如果三種需求都集中在 Employee,它就背了太多不同的改變理由。
容易失控的寫法
class EmployeeService
{
public function calculateSalary(Employee $employee): int
{
return $employee->baseSalary() + $employee->bonus();
}
public function save(Employee $employee): void
{
// 寫入資料庫
}
public function renderProfile(Employee $employee): string
{
// 組合 HTML
return '<p>' . $employee->name() . '</p>';
}
}這個類別現在知道薪資規則、資料儲存和 HTML。任何一項需求變更,都可能碰到同一個檔案。類別不是不能有三個方法,而是這三個方法的變動原因彼此無關。
讓職責各就各位
class SalaryCalculator
{
public function calculate(Employee $employee): int
{
return $employee->baseSalary() + $employee->bonus();
}
}
class EmployeeRepository
{
public function save(Employee $employee): void
{
// 將員工資料寫入資料庫
}
}
class EmployeeProfileRenderer
{
public function render(Employee $employee): string
{
return '<p>' . $employee->name() . '</p>';
}
}拆開後,薪資規則、儲存方式與顯示格式各自有清楚的責任邊界,也比較容易單獨測試。SRP 不是「每個類別只能有一個 public method」,而是「不要把不同的變動原因綁在一起」。
O:開放封閉原則(OCP)
開放封閉原則是:軟體實體應該對擴充開放,對修改封閉。意思不是原始碼永遠不能改,而是當你新增一種行為時,最好能透過新增實作來完成,避免反覆修改已經穩定的核心流程。
if 越長,咖啡越苦
假設結帳服務用條件判斷處理每種折扣:
function discountFor(string $memberType, int $amount): int
{
if ($memberType === 'regular') {
return 0;
}
if ($memberType === 'vip') {
return (int) ($amount * 0.1);
}
if ($memberType === 'staff') {
return (int) ($amount * 0.3);
}
return 0;
}剛開始只有兩種會員,看起來很方便。等到加入生日折扣、活動折扣、合作夥伴折扣,這個函式就會變成一條需要小心穿越的 if 迷宮。迷宮本身沒有錯,但維護它通常不會讓人心情變好。
將變動的規則抽成策略
interface DiscountPolicy
{
public function discountFor(int $amount): int;
}
final class VipDiscount implements DiscountPolicy
{
public function discountFor(int $amount): int
{
return (int) ($amount * 0.1);
}
}
final class StaffDiscount implements DiscountPolicy
{
public function discountFor(int $amount): int
{
return (int) ($amount * 0.3);
}
}
final class Checkout
{
public function __construct(
private DiscountPolicy $discountPolicy,
) {
}
public function total(int $amount): int
{
return $amount - $this->discountPolicy->discountFor($amount);
}
}新增折扣規則時,可以新增另一個 DiscountPolicy 實作,Checkout 不必跟著修改。這就是常見的 Strategy(策略)做法,也是 OCP 的一種實現方式。
不過,OCP 不代表每一個小函式都要先抽介面。尚未出現變動點時過度抽象,反而會增加理解成本。先觀察真正會變的地方,再決定要不要抽象,通常比較務實。
L:里氏替換原則(LSP)
里氏替換原則要求:如果程式碼期待的是某個父型別或介面,那麼換成它的子型別或實作後,原本的正確性與使用方式仍然成立。
重點不是「有 extends 就算繼承成功」,而是子類別必須遵守父類別對外做出的契約。若父類別承諾「呼叫 withdraw() 會扣款」,子類別就不能突然改成「遇到某種帳戶就丟出不支援例外」,否則呼叫端就得先猜對方是哪一種物件。
會讓呼叫端踩雷的繼承
class Bird
{
public function fly(): void
{
// 飛行
}
}
class Penguin extends Bird
{
public function fly(): void
{
throw new LogicException('企鵝不會飛');
}
}
function letBirdFly(Bird $bird): void
{
$bird->fly();
}letBirdFly() 接受 Bird,照它的契約自然會呼叫 fly()。傳入 Penguin 後卻必定失敗,代表這個繼承關係只是在語法上成立,在行為上卻不成立。
讓抽象符合真正能力
class Animal
{
}
interface Flyable
{
public function fly(): void;
}
class Sparrow extends Animal implements Flyable
{
public function fly(): void
{
// 飛行
}
}
class Penguin extends Animal
{
// 企鵝是動物,但不是 Flyable
}這裡不是把企鵝排除在鳥類之外,而是避免對「會飛」做出錯誤承諾。設計抽象時,先問「這個型別真的能保證什麼行為?」比先問「它是不是某某的子類別?」更重要。
I:介面隔離原則(ISP)
介面隔離原則主張:不要讓使用者依賴它用不到的方法。這裡的使用者可以是實作介面的類別,也可以是呼叫該介面的其他程式碼。
一個什麼都管的介面
interface Worker
{
public function code(): void;
public function test(): void;
public function cook(): void;
public function clean(): void;
}如果 Developer 只需要寫程式與測試,卻被迫實作 cook() 和 clean(),介面就太胖了。更麻煩的是,介面新增一個方法時,所有實作者都可能被迫同步修改。
拆成能組合的小介面
interface Coder
{
public function code(): void;
}
interface Tester
{
public function test(): void;
}
final class Developer implements Coder, Tester
{
public function code(): void
{
// 寫程式
}
public function test(): void
{
// 執行測試
}
}小介面讓依賴關係更精準,也讓類別可以用組合表達能力。介面不是自助餐,不需要把所有方法都端上桌;端太多,最後通常只會剩下沒人想吃的抽象。
D:依賴反轉原則(DIP)
依賴反轉原則有兩層意思:
- 高層模組不應直接依賴低層模組;兩者都應依賴抽象。
- 抽象不應依賴細節;細節應依賴抽象。
高層模組是業務規則,例如「註冊使用者」;低層模組則可能是 MySQL、檔案系統或第三方 API。業務規則不需要知道資料是存在哪一台資料庫,才能完成自己的工作。
把具體資料庫寫死
class MySqlUserRepository
{
public function save(string $email): void
{
// 寫入 MySQL
}
}
class RegisterUser
{
private MySqlUserRepository $repository;
public function __construct()
{
$this->repository = new MySqlUserRepository();
}
public function handle(string $email): void
{
$this->repository->save($email);
}
}RegisterUser 自己建立 MySqlUserRepository,所以測試時很難替換成記憶體版本,未來改用其他儲存方式也得修改業務類別。
讓高層依賴抽象
interface UserRepository
{
public function save(string $email): void;
}
final class MySqlUserRepository implements UserRepository
{
public function save(string $email): void
{
// 寫入 MySQL
}
}
final class RegisterUser
{
public function __construct(
private UserRepository $repository,
) {
}
public function handle(string $email): void
{
$this->repository->save($email);
}
}現在 RegisterUser 只依賴 UserRepository 這個抽象,具體實作透過建構子注入。測試時可以傳入一個測試用 repository;正式環境則傳入 MySqlUserRepository。這通常也被稱為依賴注入,但要注意:依賴注入是達成解耦的一種手法,DIP 是設計原則,兩者不是同義詞。
SOLID 與設計模式怎麼分?
SOLID 是設計時的原則;設計模式則是針對常見問題整理出的解法。兩者可以互相呼應,但不是一對一的配對表。
| 原則 | 常見呼應方式 | 重點 |
|---|---|---|
| SRP | Facade、Repository | 把不同變動原因分開 |
| OCP | Strategy、Decorator | 新增行為時減少修改核心 |
| LSP | 多型、合約測試 | 子型別遵守抽象契約 |
| ISP | 小型角色介面 | 只依賴真正需要的能力 |
| DIP | Dependency Injection、Factory | 高層依賴抽象 |
例如,Factory 解決的是「物件怎麼建立」,Singleton 解決的是「限制實例數量」。它們都可能出現在某個遵循 DIP 的設計裡,但使用 Singleton 不會自動讓程式變得 SOLID;全域狀態該難測,還是會難測。
實務上怎麼使用 SOLID?
SOLID 最適合拿來處理「已經看見的變動」與「正在造成痛苦的耦合」,不適合當成開工前的儀式。你可以用下面幾個問題做快速檢查:
- 這個類別是否同時受到不同角色的需求影響?如果是,回頭看 SRP。
- 每次新增類型都要修改同一串條件判斷嗎?如果是,考慮 OCP。
- 子類別是否需要丟出「這個方法我不支援」的例外?如果是,檢查 LSP。
- 類別是否被迫實作或依賴一大堆用不到的方法?如果是,看看 ISP。
- 業務邏輯是否直接
new出資料庫、郵件服務或第三方 SDK?如果是,考慮 DIP。
最後也別忘了 YAGNI:還沒有需求、沒有變動跡象時,先寫清楚簡單的程式碼,通常比預先蓋一座抽象城堡更好維護。SOLID 的目標是降低變更成本,不是增加類別數量。類別多到需要地圖才能找到入口,那就不是 SOLID,是迷宮設計。
總結
SOLID 五原則可以濃縮成一句話:讓程式碼的責任清楚、變動可控、依賴可替換,並且讓抽象真正符合它承諾的行為。
你不需要一次重寫整個專案。下一次看到超長類別、巨型 if、不自然的繼承,先挑一個最痛的地方改善,跑測試,再觀察設計是否更容易理解。好的設計通常不是一杯咖啡喝完突然出現,而是每次修改少一點心驚膽跳。
參考資料
- The Single Responsibility Principle — Robert C. Martin:說明 SRP 的「一個改變理由」觀點。
- The Open Closed Principle — Robert C. Martin:介紹 OCP 與擴充、修改之間的取捨。
- Liskov Substitution Principle — Wikipedia:整理里氏替換原則的正式定義與背景。
- PHP 手冊:物件介面:查閱 PHP 介面的宣告與實作規則。
- PHP 手冊:建構子與解構子:查閱範例中建構子注入所使用的 PHP 物件語法。

