「建構子依賴的是一個介面,不是具體類別,換掉底層實作應該不會影響到呼叫端吧?」
昨天談到,依賴注入容器本身是一把雙面刃:它讓「依賴介面而不是具體實作」這件事變得容易做到,卻也在容器設定裡藏了一層 AI 讀程式碼時看不到的邏輯。今天用一個具體案例,把這個雙面性落地——一次「換底層實作」的改動,明明契約沒變、測試也過,卻悄悄繞過了一段本來該生效的邏輯。
假設系統裡有一個 NotificationSender 介面,負責發送通知。容器設定裡實際綁定的方式是這樣:
❌ AI 只看到呼叫端的型別提示:
class OrderService
{
public function __construct(
private NotificationSender $sender
) {}
}
// AI 判斷:型別提示是介面,換底層實作應該很安全
但容器實際組裝 NotificationSender 的方式,並沒有直接綁定到某個發送通知的具體類別,而是綁定到一個裝飾器:
✅ 容器設定裡實際發生的事:
$container->bind(NotificationSender::class, function ($c) {
$rawSender = new SmtpNotificationSender($c->get('mailer'));
return new RateLimitedNotificationSender($rawSender, $c->get('rateLimiter'));
});
// 呼叫端拿到的不是 SmtpNotificationSender 本身,
// 而是包了一層「限流邏輯」的裝飾器
RateLimitedNotificationSender 這個裝飾器的職責,是在真正發送通知之前先檢查頻率限制——避免同一個使用者在短時間內被灌爆一堆通知信。這段邏輯不在介面契約裡,介面只定義了「發送通知」這個方法簽章,完全沒有規定「發送前要不要先做限流檢查」。
有一次的改動需求是:把發送通知的方式從 SMTP 換成一個第三方通知服務。AI 接到這個任務,查看程式碼後判斷:OrderService 依賴的是 NotificationSender 介面,只要新的實作也符合這個介面契約,換掉底層應該是安全的重構。
於是 AI 寫了一個新的 ThirdPartyNotificationSender 類別,符合介面契約,接著直接修改了容器裡綁定 SmtpNotificationSender 的那一行,把它換成新類別:
❌ 只換掉裝飾器包住的那一層:
$container->bind(NotificationSender::class, function ($c) {
$rawSender = new ThirdPartyNotificationSender($c->get('apiClient'));
return new RateLimitedNotificationSender($rawSender, $c->get('rateLimiter'));
});
// 看起來只是換了實作,裝飾器邏輯應該還在……
這段改動看起來沒問題,測試也全部通過——因為單元測試裡呼叫 OrderService 時,容器仍然回傳一個包了限流邏輯的物件。問題出在另一個地方:專案裡還有一段背景排程程式碼,繞過主容器,直接手動 new ThirdPartyNotificationSender(...) 來發送批次通知,因為當初這段程式碼是為了效能考量、避免走完整的容器解析流程而手寫的。AI 在查證「這個類別還有哪些地方被直接建構」時,沒有意識到需要搜尋的不只是介面的呼叫端,還包括所有繞過容器、直接 new 具體類別的地方——而這正是限流邏輯真正被繞過去的入口。
介面契約規定的是「這個方法接受什麼、回傳什麼」,不規定「呼叫這個方法之前後,還有沒有其他邏輯會發生」。 裝飾器模式的本質,就是在不改變介面契約的前提下,附加額外的行為——這正是它好用的地方,卻也是它容易被 AI 忽略的地方:AI 讀程式碼時习慣性地把「依賴介面」等同於「這個依賴的完整行為都寫在介面文件裡」,但實際上裝飾器加的行為,只存在於容器設定那一行,不存在於介面本身。
用一組對照來看這件事:
❌ 只驗證介面契約沒變:
「新類別實作了 NotificationSender 介面,
方法簽章一致,測試通過,可以安全替換。」
→ 沒有查證容器實際組裝這個依賴時,
中間還包了哪些裝飾器、加了哪些額外邏輯
✅ 先查容器怎麼組裝這個依賴:
「先看容器設定裡 NotificationSender 是怎麼 bind 的,
發現外層包了一個限流裝飾器;
換底層實作時要維持這個裝飾器結構,
並且搜尋一遍有沒有繞過容器、直接 new 具體類別的地方。」
→ 把容器組裝邏輯當成需要查證的一部分,
不是只看介面契約
AI 對「介面契約沒變、應該安全」給出的信心,只涵蓋了它讀過的那份介面定義,沒有涵蓋容器設定裡另外附加的行為,也沒有涵蓋繞過容器的例外情況。 這正是這個系列反覆出現的模式——自信的範圍,跟實際查證的範圍不一致。
DI 容器讓「依賴介面而不是具體實作」這個架構原則變得容易落地,這是它的價值;但容器設定本身也是一段程式碼,裡面可能藏著裝飾器、可能藏著條件式綁定、可能藏著跟環境有關的組裝邏輯——這些邏輯沒有寫在介面裡,也不會出現在型別提示上,只有真正去讀容器設定的那個人才看得到。 對 AI 來說,「查看容器怎麼組裝一個依賴」跟「查看介面定義」是兩個必須分開執行的查證步驟,缺一步都可能讓改動看起來安全、實際上留下缺口。
回想你手上系統裡用到 DI 容器的地方:如果現在要換掉某個依賴的底層實作,你知道容器設定裡有沒有裝飾器、條件式綁定包在外面嗎?如果不確定,你會怎麼查?
new 具體類別的另一段程式碼,換底層實作時沒有搜尋到這種例外情況明天要換一個角度:測試邊界跟架構邊界的關係——一段程式碼能不能被獨立測試,其實反映了它的架構乾不乾淨,這正好可以拿來當一種「架構品質」的檢驗方法。