安安~我是ChiYu~
昨天,我把工作項目的生命週期與通知傳送拆成兩條 Use Case,但它們仍在同一個 API Host 裡執行。通知流程只認得 IWorkItemNotificationSender,實際使用 Demo 還是 Queued Provider,則由 Composition Root 決定。
這條 Port 已經解決目前的 Provider 替換需求,卻還沒有提供兩種保護:Use Case 若直接引用具體 Provider,Compiler 不會阻止;介面接收的仍是 EF Core 使用的 WorkItem Entity,也不適合直接成為跨團隊套件契約。
十次 Session 跑完後,三個「保留現有 Port」候選都停在零 Production Diff;Namespace 方案多了一道能攔截明列引用的 CI 防線;Assembly 方案則由 Compiler 強制依賴方向,同時增加 Contract、Mapping、兩個 Class Library 與 Project 設定。
本系列最後保留現有 Port。原因是目前仍是同一團隊、同一版本、同一個 Host 與同一份部署產物,還沒有反覆錯誤引用或獨立套件需求。更重要的是,後續壓力測試確認:等跨團隊與獨立版本真的出現,再升級成 Assembly 邊界,遷移路徑仍然存在。
所以今天要判斷的,不是哪一層邊界看起來最完整,而是目前到底要攔住哪一種風險,以及這項風險值不值得現在支付相應成本。
架構邊界先找出相對穩定的政策,讓政策擁有自己需要的契約,再由資料庫、Framework、第三方服務與其他易變細節向內實作。外部技術改變時,核心規則才不必跟著重寫。
在今天的案例裡,NotificationDispatchPolicy 負責選擇一般逾期或升級通知、計算重試時間,以及依 Provider ACK 決定標記完成或安排重試。這些是通知流程希望保持穩定的政策。
Demo 與 Queued Provider 負責實際傳送方式,屬於可能替換的細節。IWorkItemNotificationSender 則是兩者之間的接縫,讓政策只認識「傳送通知的能力」,不必知道目前選用哪一個 Provider。
知道政策與細節在哪裡之後,還不能直接決定要拆幾個 Project。我會再問五個問題:
這就是 Option Value:今天不必把所有未來做成 Production Code,但要保留可升級的接縫與重新評估條件。現有 Port 若能讓未來遷移保持局部,延後物理拆分就是一個有意識的選擇,不是拖延。
把〈架構邊界〉落到今天的 .NET 案例時,我先處理一件事:核心政策擁有自己需要的契約,外部細節反過來依賴並實作它。UI、資料庫、Framework 與第三方服務都可以留在這條依賴方向的外側。
這裡的外掛式設計描述的是依賴方向;.NET Runtime Plugin 還包含執行期發現、載入外部 Assembly,以及處理各 Plugin 相依套件的機制,常見做法會用到 AssemblyLoadContext 與 AssemblyDependencyResolver。
今天沒有掃描 Plugin 資料夾、沒有動態載入,也沒有熱插拔。Assembly 候選只是新增 Class Library 與單向 Project Reference,因此後文統一稱為「Assembly 邊界」。它取得的是編譯期隔離,不是 Runtime Plugin、獨立程序或獨立部署。
三種方案的約束力依序提高:現有 Port 主要依靠介面、Composition Root 與 Review;Namespace 方案再加入 CI Source Scan;Assembly 則把部分規則交給 Compiler。只有未來真的需要獨立資源、故障隔離、版本或發布時,才繼續評估 Process 與 Deployment 邊界。
今天從昨天的接受版本開始:
7f7b245d8493472eb6a2df833a385ed1ddda8625
day-21-independence
想查看相同起點,可以執行:
git fetch --tags
git switch --detach day-21-independence
目前通知流程如下:
flowchart LR
HTTP["HTTP/Worker"] --> Processor["OverdueWorkItemProcessor"]
Processor --> Delivery["Notification Delivery"]
Delivery --> Port["IWorkItemNotificationSender"]
Config["Notification:Provider"] --> Root["Composition Root"]
Root --> Demo["DemoNotificationGateway"]
Root --> Queued["QueuedNotificationGateway"]
Demo --> Port
Queued --> Port
Dispatcher 只透過 IWorkItemNotificationSender 傳送通知,Demo 與 Queued Provider 也只在 Program.cs 組裝,因此具體 Provider 的選擇已被隔離。不過這條介面仍直接接收 API 裡的 WorkItem EF Entity。它足以支援同一個系統內替換 Provider,還不是能直接交付給其他團隊的跨套件契約。
HTTP Contract、SQLite Schema、Outbox、Atomic Claim、Lease、Idempotency Key、lost ACK、取消、重試與 Provider 行為都已通過基準驗證。今天不重新介紹每項規則,只把它們當成三組候選共同遵守的行為 Gate。
我使用相同的 Codex GPT-5.6-SOL-HIGH 模型,讓每種候選各跑三個獨立 Session。Repository 起點、行為 Gate 與停止條件固定,三組 Prompt 分別要求不同的邊界機制。
這組實驗用來比較三種架構做法,不是嚴格的單因子實驗。三份 Prompt 要求的產物本來就不同;每種做法重複三次,只是觀察相同判斷是否會反覆出現,不拿來計算模型成功率或宣稱單一因果。
三組候選對應三種問題:
| 情境 | 實驗假設 | 要觀察的失敗訊號 |
|---|---|---|
| 保留現有 Port | 現況已足以支援 Provider 替換 | Agent 為了交差仍新增多餘結構 |
| Namespace+Dependency Test | 不拆 Project 也能先把依賴規則寫成 CI Gate | 改名、移動或新增未涵蓋的引用方式後,錯誤依賴仍可能通過 |
| Assembly 邊界 | Compiler 能提供更強的反向引用限制 | Contract、Mapping 與 Project 成本超過目前需求 |
第一組要先確認四件事:
四項都成立時,Agent 必須停在零 Production Diff,不能為了展示成果再加 Factory、Wrapper 或第二層 Interface。
第二組仍只能使用原本的 WorkItems.Api.csproj。它要把 Provider、設定與 Composition Root 的位置整理清楚,再新增 Dependency Test,阻止 Use Case 直接引用具體 Provider Namespace。
UseCases.NotificationDelivery
│
└── 可以依賴 Consumer Port
NotificationProviders.Demo/Queued
│
└── 不得被 Use Case 直接引用
這組不會多出 DLL 或部署單位。它要回答的是:不拆 Project,能不能先讓已知的錯誤引用在 CI 自動失敗?
第三組最多可以新增兩個 Production Class Library:
Provider 不得接收 EF Entity、DbContext、HTTP Model 或 Host 設定,只能取得通知所需的簡單資料。API 高階流程依賴 Contract,Composition Root 負責認識並組裝具體 Provider。
flowchart LR
Host["WorkItems.Api<br/>唯一 Host/Composition Root"] --> Contracts["Notification Contracts"]
Host --> Providers["Notification Providers"]
Providers --> Contracts
Contracts -.禁止.-> Host
Providers -.禁止.-> Host
這組只比較 Assembly 邊界帶來的編譯期保護,不實作微服務、Runtime Plugin 或第二個部署單位。
完整 Prompt、執行參數與原始輸出都保留在公開實驗紀錄中。接下來直接比較三種機制實際攔住什麼,又各自增加多少成本。
主流程另外重跑系列接受版本、Namespace 代表候選與 Assembly 代表候選。三種方案都保留相同的 HTTP Contract、SQLite Schema、Outbox、重試、取消與重跑行為;通過相同行為 Gate 後,才進入架構比較。
三個 Agent 分別追蹤 Delivery Policy、Dispatcher、Provider 設定與 DI 註冊,最後都沒有修改任何檔案。
它們得到相同結論:
NotificationDispatchPolicy 不依賴具體 Provider。NotificationOutboxDispatcher 只依賴 IWorkItemNotificationSender。在同一團隊、同一版本與同一個 API Host 裡,現有 Port 已足以替換 Provider,因此零 Production Diff 是合理結果。不過它仍有兩個升級訊號:Use Case 直接引用具體 Provider 時,只能靠 Review 發現;介面直接接收 WorkItem EF Entity,也不適合作為獨立套件的公開契約。
Namespace 組的三次輸出很接近。它們保留 IWorkItemNotificationSender 在 Consumer 端,將 Demo/Queued Provider 搬到可辨認的位置,再把具體註冊集中到 Composition Root。
WorkItems.Api
├─ UseCases
│ └─ NotificationDelivery
├─ NotificationProviders
│ ├─ Demo
│ └─ Queued
└─ CompositionRoot
新增的 Dependency Test 會讀取 Production Source,搜尋 NotificationProviders,並額外檢查 global using。這讓明列的反向引用可以在 CI 失敗,比只靠 Review 多一層自動防線。
限制同樣清楚。Namespace 只負責組織型別,Compiler 仍允許不同 Namespace 互相引用;Source Scan 只認得測試裡列出的 Namespace、路徑與文字模式。Provider 改名、移動,或出現未納入規則的新引用形式時,測試也必須跟著更新。
三份候選都需要搬動 12 個檔案,另外維護 Composition Root 與 Dependency Test。當團隊反覆在 Review 攔下相同錯誤時,這筆成本很合理;目前還沒有這項證據。
Assembly 組新增通知 Contract 與 Provider 兩個 Class Library。Provider Project 沒有參考 API,因此只要引用 WorkItem EF Entity、DbContext 或 Host 型別,編譯就會失敗。
原本通知 Port 直接接收 WorkItem。跨越 Assembly 後,Agent 必須建立簡單的通知資料:
public sealed record NotificationDeliveryRequest(
Guid WorkItemId,
string Title,
NotificationDeliveryKind Kind,
string IdempotencyKey);
API 負責把 Entity 映射成這份 Contract,Provider 只能依賴 Contract Assembly。依賴測試則檢查:
DbContext 或 Host 型別。這份候選取得更強的編譯期限制,同時增加兩個 Class Library、公開 Contract、Mapping、Project Reference、Lock File 與測試。
這仍是靜態 Project Reference。Provider DLL 會和 WorkItems.Api.exe 一起發布,使用相同版本、發布與版本復原流程;它沒有 Runtime Plugin、熱插拔或獨立部署能力。
| 方案 | 行為結果 | 新增保護 | 結構成本 | 仍未取得 |
|---|---|---|---|---|
| 現有 Port | 既有行為維持 | Policy 不依賴具體 Provider | 零 Production Diff | 自動化反向引用檢查 |
| Namespace+測試 | 既有行為維持 | CI 攔截明列的錯誤引用 | 搬移檔案、維護掃描規則 | 編譯期隔離 |
| Assembly 邊界 | 既有行為維持 | Compiler 限制 Project 依賴 | 兩個 Class Library、Contract、Mapping | Runtime Plugin、獨立部署 |
Codex Session 的成本也依相同順序增加:現有 Port 的 Token 與時間最低,Namespace 居中,Assembly 最高。
這些數字只描述本次產生與驗證三種結構的工作量。三組 Prompt 要求的產物不同,每次探索與工具輸出也不同,不能換算成「多一個 Project 固定增加多少 Token」。這次也沒有量測未來需求進來後,哪一種結構一定更省 Token;完整中位數保留在公開實驗紀錄中。
保留現有 Port 有一個合理疑問:今天省下拆分成本,會不會讓未來付出更大的遷移代價?
我從相同的 day-21-independence 起點加入一個原本不存在的新情境:
Notification Provider 改由另一個團隊負責,而且必須以獨立版本的套件交付。Provider 不得引用
WorkItems.Api、EF Entity、DbContext、HTTP Contract 或 Host 組態。
這項壓力測試不納入目前需求的候選,只檢查現有 Port 是否保留升級路徑。
Agent 從 IWorkItemNotificationSender 出發,新增 Contract 與 Provider 兩個 Class Library,把跨界資料縮成 Provider 真正需要的簡單型別,再補上 Assembly Dependency Test。
flowchart LR
subgraph Before["需求出現前"]
Api1["WorkItems.Api"] --> Port1["IWorkItemNotificationSender"]
Api1 --> Providers1["Demo/Queued"]
end
subgraph After["跨團隊與版本需求出現後"]
Api2["WorkItems.Api"] --> Contract["Notification Contracts"]
Api2 --> Providers2["Notification Providers"]
Providers2 --> Contract
end
跨團隊與獨立版本需求出現後,這次遷移修改 19 個檔案、共 456 行 Diff;一開始就拆 Assembly 的三份候選,Diff 中位數則是 745 行。
兩者不是等價任務,不能解讀成延後拆分一定比較便宜。這次只確認現有 Port 沒有堵死升級路徑,需求出現後再建立 Assembly 邊界,成本仍在可處理範圍內。NuGet 發布、獨立版本 Pipeline 與跨團隊交付仍要另外驗證。

圖:邊界應停在目前足以保護需求的最小層級,只有錯誤引用、編譯隔離或獨立部署壓力出現時才升級。
壓力測試完成後,我才決定本系列接續哪一份版本:
| Repository 情境 | 選擇 |
|---|---|
| 同一團隊、同一版本、同一部署,只需替換 Provider | 保留現有 Port,本次採用 |
| Review 反覆發現 Use Case 引用具體 Provider | Namespace+Dependency Test |
| Provider 有獨立套件、相依套件、版本或負責團隊 | Assembly/Package 邊界 |
| 需要獨立資源、故障隔離或啟停 | Process 邊界 |
| 需要獨立發布、擴充、版本復原與值班 | Deployment 邊界 |
今天沒有新增 Production Code Commit。day-22-architecture-boundary 與昨天的 Tag 指向同一個 Commit;這份空 Diff 是完成架構判斷後刻意保留的結果,不是漏做實作。
git fetch --tags
git switch --detach day-22-architecture-boundary
跨任務都應重複執行的判斷流程,適合整理成 Repository Instruction;只在目前情境成立的架構選擇,則適合用 ADR 保存原因與重新評估條件。
今天有兩種資訊需要分開:
AGENTS.md 可以要求 Agent 每次先檢查 Ownership、版本、部署、既有接縫與停止條件。如果把暫時決策寫成「永遠禁止拆 Project」,下一次情境改變時,反而會阻止 Agent 做出正確選擇。
# ADR:目前保留通知 Port,等跨團隊或獨立版本壓力出現再升級 Assembly 邊界
## 決策
目前保留既有 Consumer Port、Provider 與 Composition Root,
不新增 Namespace 搬移、Class Library、Process 或部署單位。
## 重新評估條件
- 多次出現 Use Case 直接引用具體 Provider:加入 Namespace Dependency Test。
- Provider 由不同團隊維護,或需要獨立套件、版本:升級 Contract+Provider Assembly。
- 需要獨立啟停、資源或故障隔離:評估 Process 邊界。
- 需要獨立發布、擴充、版本復原與值班責任:評估 Deployment 邊界。
這份 ADR 同時記錄目前做法、刻意延後的成本與重新評估條件。下一個 Agent 不必猜上次為什麼沒拆,只要比對 Repository 現況是否已碰到觸發條件。
同樣是通知 Provider,小型團隊與單一部署可能只需要 Consumer Port;跨團隊套件會需要 Assembly/Package;獨立故障與擴充才可能需要 Process 或 Deployment。
C — Context-Aware Code 情境感知 要求 User 提供 Repository 已存在的 Port、Provider、Ownership、版本、部署方式與過往錯誤紀錄。Agent 取得這些情境後,才有能力判斷哪一層保護現在值得支付。
「把通知模組改乾淨」沒有可驗收邊界。更適合交給 Agent 的 Prompt 可以寫成:
目前 Demo 與 Queued Provider 可由設定替換,
但仍由同一團隊、同一版本、同一個 ASP.NET Core Host 發布。
先確認既有 Consumer Port 與 Composition Root 是否已隔離具體 Provider。
若已足夠,允許零 Production Diff;不得為展示架構新增 Project、Factory 或 Wrapper。
只有出現反覆錯誤引用、獨立套件、版本、跨團隊契約、程序或部署需求時,
才升級相應層級的邊界,並回報新增 Contract、Mapping、Project、DLL 與維運成本。
E — Explicit Intent and Boundaries 意圖明確 要把三件事交代清楚:什麼情況可以停在零 Diff、哪些風險會啟動更強邊界,以及這次禁止順便增加哪些結構。Agent 可以自行處理 Contract、DI、檔案搬移與測試,但不能把 Assembly 隔離一路擴張成第二個 Host。
回到標題,既有介面已經隔離目前的 Provider 選擇,因此現階段不需要為了「看起來更乾淨」加上 Namespace 搬移或 Assembly。
當 Use Case 反覆直接引用具體 Provider,Namespace+Dependency Test 才能把相同 Review 問題變成自動防線;當 Provider 需要獨立團隊、套件、版本或相依套件時,Assembly 邊界提供的編譯隔離才值得支付 Contract、Mapping 與 Project 成本。
這次壓力測試也確認,現有 Port 沒有堵死後續升級。現在零 Diff 是有停止條件的決策,不是漏做。Clean Code 幫我分清政策與細節;CLEAN 的 C、E 則把 Repository 情境、升級條件與停止點留給 User 掌握。
明天會繼續檢查 Controller、EF Core 與第三方 SDK 的型別,是否已越過這條邊界並反過來塑造 Use Case。