前面用過不只一次「邊界不清楚時,一次改動會波及意外的地方」這類反面案例。今天反過來,用一個正面案例,看清楚的介面邊界替一次「大範圍替換底層實作」的重構,實際上省下了什麼。
情境是這樣:系統裡有一個快取層,全站有幾十處程式碼會呼叫它——存資料、取資料、清除某個 key。這個快取層底層原本包著一個第三方套件,套件停止維護了,團隊決定換成另一個功能相近、但 API 完全不同的套件。
這種「換掉一個被廣泛使用的底層套件」的重構,聽起來就是那種容易牽一髮動全身的高風險工作。但這次的情況不太一樣:幾十處呼叫端,全部呼叫的都是同一個內部定義的快取介面(例如 get(key)、set(key, value, ttl)、forget(key) 這幾個方法),沒有任何一處呼叫端直接引用底層套件的類別或方法。
在之前的案例裡(沒有清楚邊界的情境),AI 最頭痛的問題是「這個改動的影響範圍到底有多大,我沒辦法窮舉」。這次完全不用面對這個問題:只要確認「所有呼叫端都經過同一個介面」這個前提成立,剩下要做的事就收斂成一件——把介面底下的實作換掉,呼叫端完全不用動。
驗證這個前提本身也很直接:在程式碼庫裡搜尋底層套件的類別名稱,如果只有快取層內部的一個檔案引用到它,前提就成立;如果搜出散落在其他地方的直接引用,那些地方要先處理掉,才能真正安全替換。
用一組對照來看這個差異:
❌ 沒有統一介面,呼叫端各自直接依賴套件:
// 呼叫端 A
$cache = new LegacyCacheClient($config);
$cache->fetch($key);
// 呼叫端 B(另一個模組,直接 new 了另一份實例)
$client = new LegacyCacheClient($otherConfig);
$client->fetch($key);
→ 要換套件,得先靠 grep 找出全部直接引用的地方,
還要擔心有沒有漏掉、有沒有隱藏在動態呼叫裡的地方
✅ 呼叫端只依賴內部定義的介面:
interface Cache
{
public function get(string $key): mixed;
public function set(string $key, mixed $value, int $ttl): void;
public function forget(string $key): void;
}
// 呼叫端只知道 Cache 介面,不知道底層是什麼套件
class OrderService
{
public function __construct(private Cache $cache) {}
}
→ 換底層套件只需要改一個實作類別,
呼叫端的程式碼一行都不用動,也不用逐一排查
介面邊界的價值在這裡具體展現出來:它把「要不要窮舉所有呼叫端」這個問題,變成「只要確認邊界本身乾不乾淨」這個更小的問題。 前者的工作量隨呼叫端數量線性成長,後者是一次性的、跟呼叫端數量無關的檢查。
因為呼叫端只認介面、不認底層實作,這次重構還多了一個選項:可以讓新舊兩個實作短暫並存,用某種開關(例如一個設定值)決定當下用哪個實作,逐步把流量從舊套件切到新套件,隨時可以切回去。這種漸進式做法在「呼叫端直接依賴具體套件」的情境下幾乎不可行——因為切換的單位會被迫是「全部呼叫端」,沒辦法切一部分。
這正是這個系列反覆講的模式的另一個面貌:AI(或任何人)敢不敢對一個大範圍改動有信心,取決於它需要承擔的查證範圍有多大——介面邊界不是讓查證變得不必要,而是把查證範圍收斂到一個可以被完整檢查的邊界內。
回想你手上系統裡有沒有一個被廣泛使用的底層元件(快取、日誌、外部 API client):如果今天要換掉它的底層實作,你有把握列出所有呼叫端嗎,還是得靠一次一次的 grep 慢慢找?
明天要看依賴方向規則被忽略時的真實教訓——一次「看起來方便」的捷徑,事後付出了什麼代價。