以介面、模組與轉接層連結軟體設計原則的抽象程式碼示意圖

SOLID 設計原則與適配器模式:從概念到實作

軟體設計原則不是用來背名詞,而是用來降低變更成本。當一個類別同時處理資料庫、商業規則與通知,或每次新增功能都要修改一長串條件判斷,程式就會變得難以測試與維護。SOLID 原則提供一組檢查方向;設計模式則提供可重複使用的解法。

本文保留原文關注的開放封閉原則(OCP)、單一職責原則(SRP)、介面隔離原則(ISP)與適配器模式,並補上里氏替換原則(LSP)和依賴反轉原則(DIP),用小型範例說明何時該拆分責任、引入抽象,或在兩個不相容的介面之間加一層轉接。

設計原則與設計模式有什麼不同?

設計原則是判斷設計品質的方向,例如「一個類別不應有太多互不相關的變更原因」;設計模式則是針對常見情境整理出的物件合作方式,例如適配器模式。原則不是硬性規則,也不代表類別越小、介面越多就一定越好;應依變更頻率、測試成本與系統邊界取捨。

SOLID 五項原則速查

縮寫 原則 實務問題
S 單一職責(SRP) 這個模組是否有多個不相關的變更原因?
O 開放封閉(OCP) 新增行為能否擴充,而不用反覆改動穩定的核心?
L 里氏替換(LSP) 替代實作是否仍符合呼叫端預期的契約?
I 介面隔離(ISP) 實作類別是否被迫依賴用不到的方法?
D 依賴反轉(DIP) 高層規則是否直接綁定某個資料庫或第三方 SDK?

1. 單一職責原則(SRP)

SRP 的重點不是「一個類別只能有一個方法」,而是一個模組應該只有一個主要的變更理由。例如訂單服務若同時計算折扣、寫入資料庫、產生 PDF 和寄送通知,任何一項需求改變都會觸碰同一個類別。

class OrderService {
  calculateTotal(order) { /* 商業規則 */ }
  saveToDatabase(order) { /* 資料存取 */ }
  renderPdf(order) { /* 輸出格式 */ }
  sendEmail(order) { /* 通知 */ }
}

可以先依變更原因拆成 OrderPricingOrderRepositoryOrderRendererOrderNotifier。拆分後,折扣規則的測試不必啟動資料庫,PDF 格式更新也不會影響訂單計算。

2. 開放封閉原則(OCP)

OCP 表示模組應對擴充開放、對修改封閉。它不是禁止所有修改,而是把容易變動的部分隔離在擴充點,避免新增一種付款方式就改動核心流程中的大量 if/else

interface Payment {
  pay(amount: number): PaymentResult;
}

class Checkout {
  constructor(private payment: Payment) {}

  complete(amount: number) {
    return this.payment.pay(amount);
  }
}

新增信用卡、轉帳或測試用付款實作時,只要實作同一個契約並在組態層選擇它;若每個新案例都必須修改 Checkout,就表示擴充邊界可能放錯位置。

3. 里氏替換原則(LSP)

LSP 要求替代實作仍遵守基底型別或介面對呼叫端做出的承諾。若某個子類別不接受基底類別允許的輸入、改變回傳意義,或突然丟出呼叫端未預期的例外,它雖然「看起來」可以替換,實際上卻破壞了契約。

interface DocumentStore {
  read(id: string): string;
  write(id: string, body: string): void;
}

// 只讀儲存若實作 write 後永遠丟出錯誤,
// 就不符合這個介面對呼叫端的承諾。

遇到這種情況,應重新定義較小的 ReadableStoreWritableStore,或明確表達唯讀能力,而不是讓呼叫端靠型別猜測例外行為。

4. 介面隔離原則(ISP)

ISP 主張介面應該小而聚焦,實作類別不應被迫依賴不需要的方法。把列印、掃描、傳真全部塞進一個 MultiFunctionDevice,會讓只支援列印的裝置也必須提供空方法或丟出例外。

interface Printer {
  print(file: File): void;
}

interface Scanner {
  scan(): File;
}

class SimplePrinter implements Printer {
  print(file: File) {
    // 只負責列印
  }
}

小介面也能讓測試替身更簡單。呼叫端只依賴自己真正使用的能力,未來替換實作時需要同步修改的範圍會比較小。

5. 依賴反轉原則(DIP)

DIP 的核心是高層政策依賴抽象,而不是直接依賴低層細節。依賴注入是常見的落地方式:由組態或啟動程式建立具體物件,再把它交給需要的服務。

interface UserRepository {
  find(id: string): User;
}

class UserService {
  constructor(private repository: UserRepository) {}

  getUser(id: string) {
    return this.repository.find(id);
  }
}

測試時可以注入記憶體版本,正式環境再注入資料庫版本。注意不要為了「套用 DIP」而替每個簡單值建立一層抽象;只有當替換、隔離或測試確實有價值時,抽象才值得維護。

適配器模式:讓不相容的介面合作

適配器模式的目的,是在不修改既有類別的前提下,把一個介面轉成呼叫端需要的另一個介面。常見情境包括整合舊 API、包裝第三方 SDK,或把外部服務的資料格式轉成領域模型。

interface WeatherProvider {
  current(city: string): Weather;
}

class LegacyWeatherApi {
  fetch_now(cityName: string) {
    return { temp_c: 26, city_name: cityName };
  }
}

class LegacyWeatherAdapter implements WeatherProvider {
  constructor(private api: LegacyWeatherApi) {}

  current(city: string): Weather {
    const result = this.api.fetch_now(city);
    return { city, temperatureC: result.temp_c };
  }
}

呼叫端只依賴 WeatherProvider,不需要知道舊 API 的方法名稱或欄位格式。將來更換供應商時,可以新增另一個適配器;測試也能直接提供假的 WeatherProvider

原則與模式如何一起使用?

  1. 先找出變更原因:需求是新增付款方式、換資料庫,還是改輸出格式?
  2. 用 SRP 把不相關的責任分開,再用 ISP 定義呼叫端真正需要的最小介面。
  3. 讓高層流程依賴抽象(DIP),並檢查每個替代實作是否遵守契約(LSP)。
  4. 只有在既有介面無法修改或外部格式不相容時,才加入適配器;不要為了使用模式而增加不必要的層次。
  5. 用測試驗證擴充點:新增實作時,既有核心測試應保持通過。

常見誤用與檢查清單

  • 把「一個類別一個方法」誤當成 SRP,卻沒有處理真正的變更耦合。
  • 為了 OCP 預先建立大量抽象,結果讓簡單功能難以理解。
  • 介面拆得太細,卻沒有清楚的使用者或替代實作,增加閱讀成本。
  • 適配器只轉名稱、不處理單位、錯誤與空值,導致資料在邊界悄悄失真。
  • 只看類別圖,不用測試確認替代實作真的符合契約。

常見問題

SOLID 需要一次全部導入嗎?

不需要。先從最常變動、最難測試或最常造成回歸的模組開始,依實際痛點逐步拆分。原則是協助做決策,不是一次性的重構清單。

設計模式越多,程式就越好嗎?

不一定。模式應該降低耦合或讓變更更容易;如果只為了符合書上的名稱而增加多層包裝,反而會讓流程更難追蹤。

適配器和外觀(Facade)有什麼差別?

適配器重點是把既有介面轉成另一個介面;外觀則是替一組複雜子系統提供較簡單的入口,通常不要求原介面與新介面互相相容。

總結

SRP 幫助你分離變更原因,OCP 讓新增行為不必反覆修改核心,LSP 保護替代實作的契約,ISP 減少不必要的介面依賴,DIP 則把高層政策與低層細節分開。當舊系統或第三方 API 的介面不相容時,再用適配器把邊界包起來。從一個真實的變更痛點開始,通常比一次套用所有模式更能得到可維護的結果。

延伸閱讀與參考資料

Similar Posts

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *