安安~我是ChiYu~
昨天把服務等級與派工政策分開後,今天我在共同通知介面後方,再放入一個 Queued Provider。Provider 就是負責實際送出通知的替代實作。
Demo 與 Queued 都能實作同一個 Interface,也都能由 DI 正常切換。可是只要其中一個把已知失敗的 false 改成 Exception、吞掉 Cancellation,或在內部自行重試,OverdueWorkItemProcessor 收到的行為就已經不同。
九份候選最後都能編譯,也能由 DI 解析。真正拉開差異的,不是 Interface 有幾個,而是三件事:兩個 Provider 是否接受同一份行為契約、正式 Consumer 是否被迫依賴用不到的能力,以及通知契約究竟由高階流程還是低階 Adapter 擁有。
實驗中,強制拆成兩個小介面的版本讓 Processor 多了一項依賴,卻沒有隔離任何新 Consumer;Contract-First 的三次執行則都先建立共用契約,也都判斷目前不需要方法級拆分。本系列最後採用其中一份 Consumer Port 版本,是為了繼續觀察 DIP 的契約所有權;若需求只是新增第二個 Provider,我反而會選擇更小的修改。
一句「請幫我套用 SOLID」,很快就能得到 Interface、Constructor Injection 與 DI Registration。型別接得起來只是起點,今天要繼續檢查 LSP、ISP 與 DIP 真正要求的行為、使用端與依賴方向。
只有一個實作時,這三項原則仍然存在,只是許多錯誤暫時不會發作。第二個 Provider 加入後,替換前後的差異才有具體對照。
| 原則 | 本篇實際驗收的問題 | 容易出現的誤判 |
|---|---|---|
| LSP/Liskov Substitution Principle 里氏替換原則 | Demo 換成 Queued 後,成功、失敗、例外與取消語意是否一致? | 兩個 Class 實作同一個 Interface,就直接宣布可以替換。 |
| ISP/Interface Segregation Principle 介面隔離原則 | Consumer 是否被迫依賴自己不會使用的能力? | 一個方法拆一個 Interface,介面越小就越符合 ISP。 |
| DIP/Dependency Inversion Principle 依賴反轉原則 | 通知契約是否由高階 Use Case 擁有,低階 Adapter 是否反過來實作它? | 使用 Constructor Injection 或 DI Container,就直接宣布依賴方向正確。 |
共同行為測試回答 LSP,Production Consumer 的使用方式回答 ISP,原始碼參考方向則回答 DIP。三者處理的是不同問題,不能用「都有 Interface」一次帶過。
OverdueWorkItemProcessor 至少依賴三項通知行為:
true。[notify-fail] 時回傳 false,Processor 累加失敗摘要,但仍保存 Overdue 狀態。CancellationToken 必須傳播取消,不能改成 false 或成功。Queued 若把已知失敗改成 Exception,它可能符合自己的內部設計,卻違反 Processor 已經依賴的契約。LSP 不要求兩個 Provider 寫法相同,而是要求替換後,呼叫端依賴的成功、失敗、取消與副作用語意不變。
Design by Contract 通常翻成契約式設計。前置條件描述呼叫端必須提供什麼;後置條件與不變量則說明完成後必須守住哪些結果與狀態。Subtype 不能突然要求更多輸入,也不能少做 Client 已經依賴的保證。
本篇的正式 Consumer 是 OverdueWorkItemProcessor,同一段流程會依升級政策選擇一般通知或升級通知:
var notificationSucceeded = requiresEscalationNotification
? await notificationSender.SendEscalationAsync(workItem, cancellationToken)
: await notificationSender.SendOverdueAsync(workItem, cancellationToken);
目前只有這一個 Consumer,而且它確實同時使用兩種通知。兩個方法也共用 Provider、生命週期、Cancellation 與失敗語意,所以留在同一個 Interface 並沒有增加使用端負擔。
等另一個 Consumer 只需要其中一項能力,或兩種通知開始使用不同權限、Provider、生命週期與部署方式,再拆才有隔離效果。ISP 看的是誰被迫依賴什麼,不是方法數量是否夠少。
執行時,Processor 會向外呼叫通知 Adapter;編譯時的原始碼依賴,則應指向高階流程需要的抽象。Processor 只需要知道「傳送 Work Item 通知」的能力,不應知道 Queue SDK、Provider Key、序列化格式或重試設定。
Runtime 呼叫方向:
OverdueWorkItemProcessor → Demo/Queued Adapter
原始碼依賴方向:
OverdueWorkItemProcessor → IWorkItemNotificationSender
Demo/Queued Adapter → IWorkItemNotificationSender
Program.cs 可以知道具體型別,因為它是 Composition Root,專門負責選擇並組合物件。DI Container 只完成物件建立與接線;如果高階 Processor 仍直接引用某家通知 SDK,改成 Constructor Injection 也沒有改變依賴方向。
這次所有型別仍編譯進同一個 .NET Assembly。Namespace 能表達邏輯所有權,Compiler 卻不會因此禁止反向引用。明天進入元件原則後,再用 Project Reference 檢查這條方向能不能成為真正的編譯限制。

圖:可以編譯只是入場券;Provider 替換還要檢查可觀察行為、Consumer 需要的介面大小與原始碼依賴方向。
這次的 QueuedNotificationGateway 不會連線到真實 Queue,也不加入 SDK、背景服務、Outbox 或自動重試。真實服務會同時帶入網路、Credential、序列化、送達保證與 Idempotency,我就無法判斷候選差異究竟來自 SOLID 邊界,還是外部服務剛好失敗。
這個可預測的本機模擬 Provider 只製造一種替換壓力:
Notification:Provider 時使用 Demo。Demo 或 Queued 時,DI 解析成不同具體 Provider。[notify-fail] 與 Cancellation 契約。這個本機模擬只回答今天的問題:Demo 與 Queued 放在同一個 Interface 後方時,已列出的行為與原始碼依賴是否相容。真實 Queue 的送達、重試與跨程序失敗不在本次結論內。
本次基準固定為:
ccbc136daa59cfcd430d44ebd4cb62b57b4ab72d
day-16-solid-lsp-isp-dip-baseline
high
想自行重現的讀者,可以從公開的 AI-CleanCode-API-Demo 切到相同起點:
git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
Set-Location AI-CleanCode-API-Demo
git switch --detach day-16-solid-lsp-isp-dip-baseline
不需要 Clone 專案才能閱讀本文。完整 Prompt、Diff 與實驗紀錄都保留在公開 Repository;Tag 主要提供想自行重播的讀者使用。
起點原本只有一個 INotificationGateway,同時提供一般與升級通知:
public interface INotificationGateway
{
Task<bool> SendOverdueAsync(
WorkItem workItem,
CancellationToken cancellationToken);
Task<bool> SendEscalationAsync(
WorkItem workItem,
CancellationToken cancellationToken);
}
這次不預先要求 Agent 保留或拆分介面。我會先看 Consumer 實際用了什麼、兩個 Provider 要守住哪些行為,再檢查契約由哪一層擁有。
如果 Demo 與 Queued 各寫一份 Unit Test,兩份測試很容易逐漸分岔。Demo 驗了取消,Queued 可能忘記;第三個 Provider 又可能只驗成功。
Contract Test 是把所有替代實作共同遵守的 Assertions 集中起來,再讓每個 Provider 套用同一份測試。我把 Provider、通知操作與結果交叉列成 Contract Matrix。兩個 Provider、兩種通知操作,再乘上成功、失敗與取消三條路徑,共有 2 × 2 × 3 = 12 個案例。
| 通知種類 | 一般標題 | [notify-fail] |
已取消 Token |
|---|---|---|---|
| 一般逾期通知 | 回傳 true |
回傳 false |
傳播 OperationCanceledException |
| 升級通知 | 回傳 true |
回傳 false |
傳播 OperationCanceledException |
接受版本使用一個抽象測試類別,兩個 Provider 各自提供建立實作的方式:
public abstract class NotificationGatewayContractTests
{
protected abstract IWorkItemNotificationSender CreateSender();
[Fact]
public async Task SendOverdueAsync_RegularTitle_ReturnsSuccess()
{
var sender = CreateSender();
var succeeded = await sender.SendOverdueAsync(
CreateWorkItem("一般逾期工作"),
TestContext.Current.CancellationToken);
Assert.True(succeeded);
}
[Fact]
public async Task SendEscalationAsync_TitleContainsFailureMarker_ReturnsFailure()
{
var sender = CreateSender();
var succeeded = await sender.SendEscalationAsync(
CreateWorkItem("需要升級通知 [notify-fail]"),
TestContext.Current.CancellationToken);
Assert.False(succeeded);
}
}
這十二個案例支持的範圍很明確:兩個 Provider 在目前列出的成功、失敗與取消情境下,對 Client 提供相同語意。未知 Exception、Retry、Idempotency 與真實 Queue 的送達保證還沒有被產品契約定義。未來加入 Timeout 或部分成功時,Contract Matrix 也要跟著擴充。
「新增 Queue Provider,請遵守 SOLID」沒有說明失敗語意,也沒有交代一般與升級通知是否屬於不同 Consumer。Agent 只能依常見 Pattern 自行補完。
E — Explicit Intent and Boundaries 意圖明確 要我先寫出成功、失敗、取消、禁止新增的能力與 Composition Root 邊界。Interface 名稱與檔案位置可以交給 Agent;成功、失敗、取消、副作用順序與不得擅自增加的 Retry、Outbox 等行為,必須由 User 先說清楚。
Provider 換掉後,既有 API Tests 全綠仍可能漏掉失敗語意、Cancellation 或副作用順序漂移。
N — Non-Surprising Behavior 符合預期 要求每份候選接受同一份 Client Contract,主流程再用獨立 Oracle 檢查 Demo/Queued 的成功、失敗、取消、Provider 型別與 Processor 依賴。Adapter 內部可以不同,對外可觀察行為必須穩定。
我讓三種方向各跑三個全新 Codex Session,共九次正式實驗。三組都使用相同 Baseline、模型、功能需求與驗收入口,也不沿用前次對話。差異集中在 User 事前固定多少設計條件;這些 Prompt 同時改變允許的抽象、禁止事項與停止條件,因此屬於三種策略的重複觀察,不是單一詞句造成結果的嚴格因果實驗。
三組 Prompt 共用下列條件:
- 新增可預測的 Queued Provider,未設定時仍使用 Demo。
- 兩個 Provider 都要支援一般通知、升級通知、已知失敗與取消傳播。
- Provider 替換不得修改 OverdueWorkItemProcessor 的業務流程。
- 保留 HTTP Contract、Database Schema、通知與 Save 順序及重跑行為。
- 不新增真實網路、套件、Retry、Outbox、背景服務或產品功能。
- 完成後執行完整 Tests、dotnet format 與 git diff --check。
三組差異如下:
| Prompt 約束 | User 先固定什麼 | Agent 還能決定什麼 | 想回答的問題 |
|---|---|---|---|
| 不指定新架構 | 需求、既有行為與修改範圍 | 是否拆介面、是否建立 Contract Test、契約放在哪裡 | Repository 現況會把 Agent 帶往哪一種最小修改? |
| 先指定兩個小介面,後文簡稱 Interface-First | 一般通知與升級通知要拆成不同介面 | 名稱、檔案、DI 與 Tests | 跳過 Consumer 分析後,方法級拆分會增加多少成本? |
| 先固定契約與 Consumer,後文簡稱 Contract-First | 先建立共用 Contract Test,再檢查正式使用端 | 保留 Gateway、建立由高階 Use Case 擁有的 Consumer Port,或有證據時拆介面 | 規定判斷流程,能否讓 Agent 保留設計自主性? |
完整原始 Prompt 都保留在公開專案:
第一組不使用 LSP、ISP、DIP 或 Contract-First 等設計關鍵字。這是控制組,用來觀察既有介面、測試與 Repository 結構本身會把 Agent 帶往哪裡。
Agent 可以沿用 INotificationGateway、拆出新介面、建立共用 Contract Test,或替兩個 Provider 各寫測試。後面兩組加入結構與決策約束後,才有基準可以比較那些指令改善了什麼,又增加了什麼。
第二組直接要求一般通知與升級通知拆成兩個介面,OverdueWorkItemProcessor 也要改成依賴拆分後的抽象。
Agent 不再判斷目前是否需要拆分,只能處理介面名稱、檔案位置、DI 註冊與 Tests。這組刻意模擬 User 把「介面越小越好」當成 ISP 實作指令,接著量測建構式參數、DI 映射與組裝成本有沒有換到實際隔離效果。
第三組要求 Demo 與 Queued 先跑同一套 Contract Test,再依 OverdueWorkItemProcessor 實際使用的能力決定邊界。
User 固定的是判斷順序與禁止事項。Agent 仍能保留既有 Gateway、建立 Consumer Port,或在發現不同 Consumer 後拆介面;每個選擇都要對得回可執行契約與正式使用端。
九份候選都由主流程重新完成 Build、既有行為測試、格式、Diff 與 HTTP Smoke Test,另外接受相同的 Host Oracle。確認 Demo/Queued 的成功、失敗、取消與 Processor 抽象依賴都成立後,我才比較三組的介面負擔與契約位置:
| Prompt 約束 | LSP 實據 | ISP 判斷 | DIP 方向 | User 看到的成本 |
|---|---|---|---|---|
| 不指定新架構 | 三次都補了 Provider Tests,契約組織方式不同 | 保留既有 INotificationGateway |
Processor 維持原依賴 | 修改最少,共用契約的可重複性由各次 Agent 自行判斷 |
| 先指定兩個小介面 | 兩個 Provider 都能履行一般與升級通知 | Processor 被改成同時依賴兩個小介面 | 仍由 Composition Root 選擇完整 Provider | 多出建構式參數與 DI 映射,沒有隔離新的 Consumer |
| 先固定契約與 Consumer | 三次都有 Demo/Queued 共用 Contract Test | 三次都判斷目前不需要方法級拆分 | 兩次建立 Consumer Port,一次保留既有 Gateway | 行為與判斷流程較一致,類別配置仍有差異 |
檔案數與新增/刪除行數等原始統計保留在完整實驗紀錄。正文聚焦在這些修改換到了什麼。
自由替換的三次結果都保留 INotificationGateway,也沒有修改 OverdueWorkItemProcessor。Agent 看見既有介面已能同時表達一般與升級通知,因此只加入 Queued Adapter、Provider Tests 與 Composition Root 選擇。
當既有介面穩定、只有一個 Consumer,而且行為契約已經被測試清楚時,沿用原介面是成本最低的選擇。未來若 Consumer 或失敗語意開始分化,這份最小修改才需要重新評估。
Interface-First 三次都產生類似結構:
public interface IOverdueNotificationGateway
{
Task<bool> SendOverdueAsync(
WorkItem workItem,
CancellationToken cancellationToken);
}
public interface IEscalationNotificationGateway
{
Task<bool> SendEscalationAsync(
WorkItem workItem,
CancellationToken cancellationToken);
}
public interface INotificationGateway
: IOverdueNotificationGateway, IEscalationNotificationGateway;
Composition Root 把同一個 Provider 映射成兩個小介面,Processor 的通知依賴也從一項變成兩項。這份程式碼能編譯、測試也通過,但目前唯一的 Production Consumer 同時需要兩種通知。介面拆分增加了建構式參數與 DI 映射,沒有隔離任何已知差異。
等到一般通知與升級通知真的出現不同 Consumer、權限、生命週期或 Provider,再採用這個方向。
Contract-First 三次都先建立 Demo/Queued 共用測試,再檢查 Processor 的實際使用方式。三次都判斷一般與升級通知由同一個 Consumer 使用,目前不需要拆成兩個方法級 Interface。
實作位置仍然出現差異:
IOverdueNotificationPort,放在 Ports 目錄。INotificationGateway,只補強共用 Contract Test。IWorkItemNotificationSender,與高階 Use Case 放在 WorkItems.Api;兩個 Adapter 留在 WorkItems.Api.Notifications。我看重 Contract-First 的地方,不是三次檔案配置完全一致,而是它們都先建立共用契約,也都先做出相同的 Consumer 判斷。User 最後仍要比較契約所有權與新增結構是否值得。
這次我不把 Run 03 寫成固定模板。需求只增加 Provider、需要共用契約、準備建立獨立元件,或真的出現不同 Consumer 時,我會採用不同設計:
| 目前情境 | 我會優先考慮的設計 | 理由 |
|---|---|---|
| 只新增第二個 Provider,既有介面穩定 | 自由替換或 Contract-First Run 02 | 修改較小,Processor 不需要搬動;補上共用契約即可。 |
| 多個 Provider 必須共享成功、失敗與取消語意 | Contract Test | 新增 Provider 時會自動接受相同 Assertions。 |
| 高階 Use Case 即將形成獨立元件 | Contract-First Run 03 的 Consumer Port | 契約由高階流程擁有,低階 Adapter 反過來實作。 |
| 不同 Consumer、權限、生命週期或部署方式已經出現 | 方法級或角色級 Interface Split | 拆分能隔離已知差異,不只增加介面數量。 |
本系列接下來沿用 Run 03:
5f596a62a9db82a0acaade6509579b449ebfcf61
day-16-formal-contract-first-consumer-port-run-03
day-16-solid-lsp-isp-dip
git switch --detach day-16-solid-lsp-isp-dip
也可以直接查看 Baseline 與接受版本的完整 Diff。
今天接受 Run 03,是因為下一篇要繼續觀察 DIP 的契約所有權。它把 IWorkItemNotificationSender 放在高階 WorkItems.Api,Demo/Queued Adapter 反過來實作,具體選擇留在 Program.cs。所有型別目前仍在同一個 Assembly,所以這只是原始碼層級的所有權線索。
如果專案已有可靠共同契約,單純新增 Provider 時,自由替換組的修改最少;若只需要補 Contract Test,不需要移動 Port,Run 02 會比 Run 03 精簡。等元件真的需要獨立建置或發布,再支付拆 Project 的成本。
三種方向各有三個 Session。Fresh Input Token 是扣除快取後重新讀入的內容量,Output Token 是模型輸出量,Tool Calls 則是 Agent 呼叫工具或終端指令的次數。
| Prompt 約束 | Fresh Input Token | Output Token | Tool Calls | 平均時間 |
|---|---|---|---|---|
| 不指定新架構 | 140,311 | 25,301 | 52.3 | 830.5 秒 |
| 先指定兩個小介面 | 135,160 | 22,698 | 61.3 | 797.9 秒 |
| 先固定契約與 Consumer | 137,434 | 22,696 | 50.7 | 748.3 秒 |
三次執行看不出任何策略具有固定 Token 優勢。Interface-First 的 Fresh Input 最低,Tool Calls 卻最高;Contract-First 的平均時間最短,Output Token 則與 Interface-First 接近。
我更在意工作順序。Contract-First 先固定 Client 行為,再決定 Interface 與 Adapter,審查時比較容易追問每個抽象為什麼存在。這是本次 Repository 的觀察,不直接外推到其他專案。
null 當成未設定,會讓 Agent 在錯誤 Oracle 上得到綠燈其中一個 Session 測試「未設定 Provider 時使用 Demo」時,寫下:
settings["Notification:Provider"] = null;
把設定值寫成 null,代表 Configuration 裡仍然存在 Notification:Provider 這個 Key;完全沒有加入 Key,才符合這次的「未設定」。主流程修正測試後,答案才對回需求。
這次誤判也落在 N — Non-Surprising Behavior 符合預期:Oracle 本身若寫錯,Agent 只會很認真地取得一個沒有意義的綠燈。
大量 AI Coding 時,我不想逐次指定要建立哪個 Interface、放在哪個資料夾。因此,我先把共同契約與選擇條件整理成一段可放進 AGENTS.md 的範例:
## Provider Contract Policy
- 替換 Provider 前,先列出 Production Consumer 依賴的成功、已知失敗、
Exception、Cancellation、Retry、Idempotency 與副作用順序;
Repository 沒有定義的項目,不得由 Agent 自行補答案。
- 兩個以上 Provider 共用同一抽象時,建立可重複套用的 Contract Test;
每個 Provider 必須執行相同 Assertions。全綠只代表已列案例成立。
- Repository 已有穩定抽象,而且高階 Consumer 沒有引用 Vendor 細節時,
保留既有 Interface,優先補齊 Contract Test。
- 高階 Use Case 需要擁有契約,或即將形成獨立元件時,才建立 Consumer Port;
具體 Provider、設定綁定與選擇留在 Adapter/Composition Root。
- 套用 ISP 前,列出每個 Interface Member 的 Production Consumer;
沒有不同 Consumer、權限、生命週期或部署影響時,不因方法數量拆分介面。
- Provider 替換不得修改 HTTP Contract、Database Schema、錯誤語意、
Cancellation、通知與 Save 順序及重跑行為。
- 未授權時,不新增 Retry、Outbox、背景服務、狀態查詢、撤回、
管理 API、真實網路呼叫或新套件。
- 完成後回報 Consumer、共用契約、失敗矩陣、Interface Members、
原始碼依賴方向、Composition Root、Tests、Diff 與停止理由。
這份 Policy 目前尚未寫入 API Demo 的 AGENTS.md,先留在文章中方便示範。規則成熟後,抽成能重複引導 Agent 執行檢查流程的 Skill 會更適合;系列最後也會把累積的 Clean Code Policy 整理成公開 Skill。
昨天已經用 SRP 與 OCP 檢查 Actor、變更理由與預期擴充。今天再補上後三問:
## SOLID Decision Check
- L/LSP:Client 依賴哪些成功、失敗、Exception、Cancellation 與副作用語意?
所有替代實作要如何接受相同驗收?
- I/ISP:每一個 Interface Member 分別由哪些 Production Consumer 使用?
是否有 Consumer 被迫依賴自己用不到的能力?
- D/DIP:高階政策需要什麼契約?誰擁有這份契約?
Adapter、SDK、Options 與 Composition Root 的原始碼依賴各自指向哪裡?
找不到契約、Consumer 或依賴方向的 Repository 證據時,先回報缺口;
現有抽象已足以處理目前壓力時,停止新增介面並說明理由。
這份 Check 要 Agent 先列出行為契約、Consumer 與依賴方向,再由測試、Diff 和 Repository 情境決定哪些抽象值得留下。類別與檔案配置仍保留給 Agent 判斷。
回到標題,兩個 Provider 都能編譯,只能證明型別相容。真正的替換還要通過三道檢查:對 Client 提供相同的成功、失敗與取消語意;Interface 沒有把 Consumer 用不到的能力一起塞進來;高階流程依賴自己需要的契約,而不是直接依賴 Vendor 細節。
本篇為了觀察 DIP 而接受 Run 03,但這不是所有 Provider 替換都要建立 Consumer Port。只有新增 Provider 時,我會選更小的修改;只有不同 Consumer、權限、生命週期或部署方式真的出現時,才拆介面。
目前這條依賴方向仍靠 Namespace 與團隊規則維持,Compiler 還不會攔住反向引用。明天進入元件原則,我會把同一份程式碼拆到 Project 層級,再檢查哪些內容真的需要一起改、一起重用與一起發布。