「我只是想讓訂單頁面多顯示一個欄位,AI 怎麼會改到用戶模組的程式碼?」
這是進入第二部後我想先處理的問題。前面幾天講過 Repository 分層、介面切分、模組邊界這些工具本身該怎麼設計;但工具存在,不代表 AI 會自動遵守它。今天用一個具體案例,示範「沒有清楚模組邊界」時,AI 怎麼在解決一個看起來很小的問題時,不小心把兩個原本該獨立變更的模組焊死在一起——當下能動、測試也過,代價要等到下一次「只想改其中一個模組」時才會顯現。
情境是這樣:訂單模組(Order)跟用戶模組(User)是兩個獨立的業務模組,各自有自己的資料模型、自己的服務層,理論上只透過對方模組公開的介面互動。需求是「訂單列表頁多顯示一個『會員等級』欄位」。
AI 拿到這個任務,最快的路徑是這樣:
❌ 直接依賴另一個模組的內部實作:
// 在 Order 模組裡
use App\User\Internal\UserRecord; // 這是 User 模組內部的資料模型,不是對外公開的介面
class OrderListPresenter
{
public function present(Order $order): array
{
$userRecord = UserRecord::find($order->userId); // 直接查 User 模組的內部資料表
return [
'orderId' => $order->id,
'memberTier' => $userRecord->tier,
];
}
}
這段程式碼能動,測試也會過——因為測試只驗證了「輸出的陣列裡有沒有 memberTier 這個欄位」,沒有驗證「這個欄位是怎麼拿到的」。但 Order 模組現在直接依賴了 User 模組一個沒有被設計成公開介面的內部類別(UserRecord)。
這正是「隱性依賴」最陰險的地方:它不會在任何測試報告裡顯示成紅字。 測試只檢查「輸出結果對不對」,不會檢查「這個結果是透過什麼路徑拿到的」——除非有專門的架構層級檢查,否則沒有任何自動化機制會告訴你「這裡跨了一條不該跨的線」。
對照有清楚邊界時 AI 會怎麼做:
✅ 透過模組公開的介面互動:
// User 模組公開一個介面
interface UserQueryService
{
public function getMemberTier(int $userId): string;
}
// Order 模組只依賴這個介面
class OrderListPresenter
{
public function __construct(private UserQueryService $userQuery) {}
public function present(Order $order): array
{
return [
'orderId' => $order->id,
'memberTier' => $this->userQuery->getMemberTier($order->userId),
];
}
}
兩個版本的輸出結果一模一樣,測試都會綠燈。差別在於:後者,User 模組往後不管怎麼調整內部資料結構(換資料表、加快取層、改成呼叫另一個服務),只要 getMemberTier() 這個介面的行為不變,Order 模組完全不用跟著改;前者,User 模組任何內部調整都可能悄悄弄壞 Order 模組,而且沒有人會在改 User 模組的當下想到要去檢查 Order 模組有沒有受影響。
AI 面對「訂單列表多顯示一個欄位」這種需求時,會傾向找「最快能拿到這個資料」的路徑——如果 User 模組沒有現成的公開介面可以查會員等級,AI 直接查內部資料表往往比「先去 User 模組新增一個公開方法,再回頭呼叫它」快得多。這不是 AI 判斷力不足,是因為沒有任何東西告訴它「這條路徑是禁止的」。 語言本身不會阻止你 use 另一個目錄底下的類別,除非有模組邊界的規則(不管是文件約定還是工具強制)明確畫出「這裡不能跨」。
跟 Day 02 的案例對照一下:Day 02 講的是「一個功能全部塞進同一個 Controller」,那是模組內部缺乏分層;今天這個案例是模組之間缺乏邊界。兩者是同一種根因在不同尺度上的樣貌——AI 在沒有約束的情況下,永遠會選擇「當下最短的路徑」,差別只在於這條捷徑跨越的是函式邊界、類別邊界,還是模組邊界。
回想你的專案裡有沒有類似的情況:某個模組直接依賴了另一個模組的內部實作,而不是透過公開介面?如果現在要調整被依賴那個模組的內部結構,你有把握列出所有會被波及的地方嗎?
明天要往前一步:光靠介面邊界還不夠,AI 還需要知道「這個設計為什麼是這樣」,不然它很可能把一個刻意的架構決策誤判成技術債——這就是架構決策紀錄(ADR)要解決的問題。