安安~我是ChiYu~
昨天,我把會獨立改變的重試規則從 Dispatcher 分離出來。但打開目前的 Solution,裡面早已有三個正式 Project,Build 後也會產生多個 DLL。這是不是代表架構早就取得獨立性了?
還不一定。
六份候選最後都能維持相同行為。Assembly 邊界確實可以讓 Compiler 阻止 Use Case 反向引用,也會帶來新的 Port、Adapter、跨界資料與 Mapping;原始碼邊界沒有這層強制力,修改與閱讀路徑卻更短。兩邊最後仍由同一個 API Host 啟動,也跟著同一份 Publish 產物部署。
本系列最後採用原始碼邊界 Run 01。原因不是 Project 越少越好,而是目前真正需要隔離的是 Work Item Lifecycle 與 Notification Delivery 的閱讀、測試與修改責任;不同資源、故障隔離、版本與獨立發布需求都還沒有出現。
〈獨立性〉要求我把四件事分開檢查:業務流程能不能各自修改、執行方式能不能調整、開發責任能不能減少互相碰撞,以及功能能不能分開發布。Project 與 DLL 只能回答其中一部分。
〈獨立性〉提出四個架構視角:Use Case、Operation、Development 與 Deployment。以下的 Class、Namespace、Assembly、Process 與 Publish 產物,是我把這四個觀念落到 .NET 專案的實驗尺度,不是書中規定的固定 Project 模板。
目前的 Work Item API 至少有兩條流程:
兩者會透過 Outbox 接續,但改逾期判斷時,不應要求開發者一起理解 lost ACK;調整重試間隔時,也不該順手碰到 Work Item 狀態轉換。
Use Case 獨立要分開的是責任與修改路徑,不是讓兩邊完全沒有資料關係。
這裡的 Operation 指系統如何運作,和一般所說的「業務操作」不同。它關心入口、流量、資源配置、啟停方式與故障隔離改變時,原本的業務流程能不能繼續使用。
目前逾期流程可以由 HTTP Action 與背景 Worker 觸發。未來若加入批次補送工具,它應能直接執行 Notification Delivery,不必假裝重新跑一次逾期判定。
不過,多個入口共用 Use Case,只代表流程可以重新組合。要取得獨立資源、個別啟停與故障隔離,還需要真正的執行程序邊界。

圖:Use Case 獨立讓 Lifecycle 與 Delivery 分開改;Operation 獨立讓 HTTP、Worker 與批次工具重新組合,兩者都不等於 Process 隔離。
Development 獨立關心的是開發協作。本篇假設 Lifecycle 與 Delivery 分別由兩個小組維護,再觀察:
這次沒有兩個真實團隊同時開發,因此不能宣稱合併衝突或交付時間真的下降。能直接檢查的是:兩邊還會共同修改哪些檔案、公開契約放在哪裡,以及 Compiler 能不能阻止錯誤依賴。
Deployment 獨立最容易被 Project 數量誤導。
Class Library 會編譯成 DLL,但本身沒有啟動入口。多個 DLL 若最後由同一次 dotnet publish 放進同一份輸出,再由同一個執行程式啟動,仍然跟著同一個版本與發布流程交付。
本文把「部署單位」定義為可以用自己的版本、發布流程與回復方式交付的一組程式產物。真的拆出第二個部署單位後,還要處理版本相容、服務探索、觀測、網路失敗、資料一致性與發布順序。沒有獨立發布或擴充需求時,先增加第二個 Host,只會提早帶入維運成本。

圖:Development 處理責任與編譯依賴,Deployment 才處理版本、發布、擴充與回復;多個 DLL 仍可能一起部署。
先把四種獨立性放在一起看,今天的實驗範圍會比較清楚:
| 獨立性 | 要保護的變化 | 本次如何處理 |
|---|---|---|
| Use Case | Lifecycle 與 Delivery 的業務規則各自改變 | 主要比較目標 |
| Operation | 呼叫入口、資源配置、擴充與故障模式改變 | 固定使用同一個 API Host,不比較執行隔離 |
| Development | 不同負責範圍能否減少修改碰撞 | 以兩個假設小組、檔案範圍與編譯依賴觀察 |
| Deployment | 版本、發布、擴充與回復方式改變 | 固定一個 API 部署產物,不新增第二個發布流程 |
把同一棟房子分成兩個房間、替房門上鎖、改成兩套能分開斷電的空間,以及蓋成能各自搬遷的兩棟房子,是四種不同程度的隔離。回到 .NET,常見邊界也有類似差別:
| 邊界 | .NET 中可能的做法 | 能直接保護什麼 | 不能直接證明什麼 |
|---|---|---|---|
| 概念/原始碼邊界 | Class、Namespace、資料夾、內部 API | 閱讀路徑、責任與檔案負責範圍 | Compiler 禁止跨區引用、獨立部署 |
| Assembly 邊界 | Class Library、單向 Project Reference | 編譯期依賴方向、公開契約 | 獨立執行、獨立部署 |
| 執行程序邊界 | 第二個可執行程式、Worker Process | 資源與故障隔離、個別啟停 | 一定能獨立發布、資料一定解耦 |
| 部署邊界 | 個別部署產物、Pipeline、版本與回復方式 | 個別發布、擴充與回復 | 業務責任一定切對、團隊不會互卡 |
這些邊界可以疊加,卻不是非走到底不可的成熟度階梯。模組化單體可以有清楚的原始碼與 Assembly 邊界,仍維持單一部署;兩個服務也可能因共用資料表與固定發布順序,依然無法獨立演進。
本次起點是昨天的接受版本:
2ae2de5abeb500d8645ee16171de9a7b9e74fa7e
day-20-two-values
想直接查看起點,可以執行:
git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git fetch --tags
git switch --detach day-20-two-values
Solution 裡原本就有可獨立執行的 WorkItems.ServiceLevelReporter,但它不參與今天的 Lifecycle/Delivery 拆分。這次 Publish 只檢查 Agent 有沒有替 API 內兩條流程新增第二個啟動入口或部署產物。起點仍由 WorkItems.Api.exe 啟動,其餘 DLL 都只是同一份 API 產物的相依項目。
目前沒有證據顯示 Lifecycle 與 Delivery 需要不同資源、個別啟停、故障隔離或獨立發布。若直接拆成兩個服務,我會同時改變執行、部署、網路與資料一致性條件,反而無法判斷差異來自哪裡。
因此,本次固定同一個 API Host、Database 與部署產物,只比較兩個問題:
共同情境如下:
每組都建立三個全新的 Codex Session。重複三次是為了觀察相同限制是否會導向相近結構,避免只看一次剛好漂亮或奇怪的輸出;這不是模型排行榜,也不足以宣稱具有統計顯著性。
第一組可以調整 Class、Namespace 與檔案位置,但不能新增正式 Project。
它必須讓兩個 Use Case 分開理解、測試與呼叫,同時保留 IOverdueWorkItemProcessor.ProcessAsync 作為相容入口。這個入口只負責協調,不能把兩邊細節重新塞回去。
第二組要把兩個 Use Case 放進不同的正式 Class Library,API 只保留 ASP.NET Core Host、Composition Root 與具體 Adapter。
我最多允許 Agent 新增三個 Class Library,但禁止增加第二個啟動程式、網路服務、Message Broker、Database 或部署產物。若建立 Port,也必須對應現在真的存在的操作,不能用 Generic Repository 把所有 EF Core 行為藏進一個萬用介面。
兩組 Prompt 的完整內容都存放在公開實驗證據。模型、起點、業務情境、行為要求與驗收條件維持相同;主要調整的是邊界強度。每組重複三次,用來觀察同一組限制是否會產生相近結構,仍不把 Agent 的隨機差異當成已完全排除的變因。
兩組候選都必須保留:
WorkItems.ServiceLevel 與 Reporter 的既有角色。這是 N — Non-Surprising Behavior 符合預期 在本次實驗中的控制條件。若 Agent 可以一邊整理架構、一邊更換外部答案,我就無法判斷差異來自邊界設計,還是行為已被改掉。
原始碼邊界的三次結果很接近。三份候選都建立 WorkItemLifecycle 與 NotificationDelivery Namespace,並把原本 OverdueWorkItemProcessor 內的逾期查詢、狀態轉換與 Outbox 建立搬到獨立類別。原本的 Dispatcher、傳送介面與 Policy 則移入 Notification Delivery 的檔案範圍。
其中 Run 02 與 Run 03 新增 IOverdueWorkItemLifecycle,Run 01 則保留具體的 OverdueWorkItemMarker。目前只有一種逾期處理實作,也沒有第二個 Adapter,因此 Run 01 沒有為了形式對稱再加一層 Interface。
整理後的 ProcessAsync 如下:
public async Task<ProcessOverdueResponse> ProcessAsync(
CancellationToken cancellationToken)
{
var processedCount = await overdueWorkItemMarker
.MarkDueWorkItemsOverdueAndCreateNotificationOutboxesAsync(cancellationToken);
var dispatchSummary =
await notificationOutboxDispatcher.DispatchPendingAsync(
cancellationToken);
return new ProcessOverdueResponse(
processedCount,
dispatchSummary.NotificationAttemptCount,
dispatchSummary.NotificationFailureCount,
dispatchSummary.FailedNotificationWorkItemIds);
}
入口現在直接讀成兩個步驟:先完成 Lifecycle 的狀態轉換與通知意圖,再交給 Delivery 傳送既有 Outbox。未來若加入只處理待送通知的批次工具,也能直接呼叫 INotificationOutboxDispatcher,不用重新跑一次逾期判定。
兩個假設小組的主要修改範圍也比較清楚:
WorkItems.Api
├─ UseCases
│ ├─ WorkItemLifecycle
│ │ ├─ OverdueWorkItemMarker
│ │ └─ AssignmentManagementEscalationPolicy
│ └─ NotificationDelivery
│ ├─ NotificationOutboxDispatcher
│ ├─ NotificationDispatchPolicy
│ └─ IWorkItemNotificationSender
├─ Data
│ └─ WorkItemsDbContext
├─ Program.cs
└─ OverdueWorkItemProcessor.cs
限制也很明確:兩邊仍在同一個 WorkItems.Api.csproj,會共同碰到 DbContext、Entity、Program.cs 與相容協調入口。它降低閱讀與日常修改碰撞,沒有用 Compiler 禁止跨區引用。
Assembly 組的三份候選有兩種做法。
Run 01 與 Run 02 新增 Lifecycle、Delivery 與共享 Model/Domain 三個 Class Library。WorkItem 與 NotificationOutbox 搬進共享 Assembly,兩個 Use Case 不需要複製 EF Entity,但共享 Model 也成為共同修改點。
Run 03 只新增 Lifecycle 與 Delivery 兩個 Use Case Library,把 EF Core 留在 API。它少了共享 Model/Domain Project,專案相依較簡單;代價是 Use Case 與 API 之間需要各自定義資料形狀與 Mapping。
Assembly 一拆開,就必須回答 Port、Adapter 與跨界資料怎麼設計。Lifecycle Library 不能直接依賴 API 裡的 DbContext,否則高階 Use Case 又會反向引用外層 Host。因此 Lifecycle 要先定義自己需要的 Store Port,API 再用 EF Core Adapter 實作。
跨越 Assembly 時,兩邊還要決定傳遞共享 Model,或使用 NotificationIntent、NotificationWorkItem、ClaimedNotification 等專用資料形狀。編譯隔離也會帶來新的跨界契約。
flowchart LR
API["WorkItems.Api<br/>唯一 API Host/EF Core Adapter"]
LIFE["WorkItems.WorkItemLifecycle<br/>Use Case"]
DELIVERY["WorkItems.NotificationDelivery<br/>Use Case"]
LPORT["Lifecycle Store Port"]
DPORT["Delivery Store Port"]
LIFE --> LPORT
DELIVERY --> DPORT
API -. 實作 .-> LPORT
API -. 實作 .-> DPORT
三份 Assembly 候選共同增加:
Dependency Test 會確認兩個 Use Case 沒有互相引用,也沒有反過來依賴 API。這項保護確實存在,新增的契約、轉換與 Project 設定則是同一個選擇帶來的成本。
主流程重新驗證 Build、Tests、Publish 與 Smoke。六份候選都保留既有 API、資料與通知語意,第一次執行會完成應處理的 Work Item,第二次執行也不會重複送出通知。行為相同後,才能把差異收斂到架構邊界。
| 獨立性 | 原始碼邊界 | Assembly 邊界 | 本次結果 |
|---|---|---|---|
| Use Case | Namespace、入口與測試分開,但同一個 Project 仍可跨區引用 | 不同 Assembly,Compiler 與 Dependency Test 可以阻止反向引用 | Assembly 提供較強保護 |
| Operation | HTTP 與 Worker 留在同一個 API Host | 相同 | 兩組都沒有取得資源或故障隔離 |
| Development | 檔案負責範圍較清楚,仍共用 DbContext、Entity 與 Program.cs |
公開契約更明確,但多出 Port、Adapter、資料形狀與 Mapping | 保護增加,協調成本也增加 |
| Deployment | Lifecycle 與 Delivery 跟著同一份 API 產物 | 新增的 DLL 仍跟著 WorkItems.Api.exe 一起發布 |
兩組都沒有取得獨立部署 |
dotnet publish 確認 Assembly 候選只是讓 API 多帶幾個相依 DLL,沒有新增啟動入口、獨立版本、發布流程或版本還原方式。它加強的是 Use Case 與 Development 的編譯期邊界;Operation 與 Deployment 維持不變。
本系列接續 source-boundary-run-01,Commit 為 7f7b245,Annotated Tag 為 day-21-independence。
可以直接切換:
git fetch --tags
git switch --detach day-21-independence
我接受 Run 01,有四個具體理由:
OverdueWorkItemMarker,沒有為單一實作增加 Lifecycle Interface。Run 02、Run 03 的 Lifecycle Interface 並沒有錯。若已存在第二種實作、測試替身或 Adapter,它就可能有清楚用途。Assembly 候選的 Port、Adapter 與 Dependency Test 也提供真實保護,只是目前沒有足夠需求支付這筆成本。
所以這次接受的是目前剛好夠用的邊界,不是宣告原始碼邊界永遠優於 Assembly。
| 真實情境 | 建議邊界 | 原因 |
|---|---|---|
| 小型團隊、單一 API 部署,只需分清楚修改責任 | 原始碼邊界 | Class、Namespace 與測試已足以降低理解成本 |
| 不同小組經常誤用對方內部型別,或需要阻止反向引用 | Assembly 邊界 | 單向 Project Reference 與 Dependency Test 能提供編譯期保護 |
| 兩個模組需要不同套件、不同版本或正式發布 NuGet Package | Assembly/Package 邊界 | 編譯與套件契約開始有獨立價值 |
| Delivery 需要不同資源、個別啟停或故障隔離 | 執行程序邊界 | 第二個 Worker Host 能提供執行隔離,也會帶來跨程序失敗 |
| 需要獨立發布、擴充、回復與值班責任 | 部署邊界 | 此時才值得承擔服務、Pipeline、觀測與資料一致性成本 |
Assembly Run 01/02 適合兩個 Use Case 必須共用相同 Domain Model,而且團隊願意共同維護這份契約的情境。Run 03 適合不想建立共享 Model Assembly,願意用 Use Case 專用資料與 Mapping 換取較少專案相依關係的情境。
沒有哪一個候選能用最低成本同時取得四種獨立性。User 要先指出目前想隔離哪一種變化,再決定要把邊界畫到哪裡。
「幫我拆成乾淨架構」或「幫我改成多 Project」沒有說明要保護的變化。Agent 只能從常見範本補空白,多建幾個 Project、每層放一組 Interface,再把資料搬過 Mapper。最後結構可能很工整,卻沒有對應任何發布、團隊或執行需求。
大量 AI Coding 時,我會先提供下面四項資訊:
請先分別評估以下四種獨立性,再提出最小可行邊界:
1. Use Case:列出會因不同原因改變的業務流程。
2. Operation:列出入口、流量、延遲、資源、啟停與故障隔離需求。
3. Development:說明負責範圍、共享檔案與需要共同維護的契約。
4. Deployment:說明目前需要幾個版本、發布流程、擴充單位與回復方式。
若目前只需要分開閱讀、測試與修改,先使用原始碼邊界。
只有出現編譯期隔離需求時,才新增 Class Library。
不得把 DLL、Project 或不同入口直接當成獨立部署證據。
完成後列出每條邊界保護的變化、增加的契約,以及尚未取得的獨立性。
這份 Prompt 不替 Agent 指定每個 Class 名稱,卻能限制它自行擴建架構的範圍。User 交代問題、邊界與停止條件,細部實作仍由 Agent 在範圍內決定。
六份候選雖然固定模型與 Reasoning Effort,各工作階段仍包含不同的設計確認、測試輸出、續跑與 Skill 載入,不能把 Token 差異全部歸因於 Project 數量。
Assembly 候選確實增加 Project、Port、Adapter、跨界資料與 Mapping。這些內容往後都可能進入 Agent 的 Repository Context,因此我推測後續閱讀與協調成本會提高;本次沒有用等價的下一項需求重新量測,不能換算成固定 Token 比例。
我把這套 Repository 層級的架構決策規則命名為 Architecture Independence Policy。它要求 Agent 先判斷要保護哪一種獨立性,再選擇原始碼、Assembly、Process 或部署邊界。這份 Policy 是實驗完成後才整理出的結果,沒有事先放進六個 Session。
## Architecture Independence Policy
- 先分別說明 Use Case、Operation、Development 與 Deployment 要保護的獨立性,不得只以「分層」或「多 Project」代替需求。
- Operation 必須交代流量、延遲、資源配置、啟停與故障隔離,不能只列出 HTTP、Worker 等呼叫入口。
- 兩項責任只需分開閱讀、測試與維護時,先使用 Class、Namespace 與資料夾建立原始碼邊界。
- 需要以編譯器禁止反向引用、具有不同相依套件,或由不同小組維護穩定契約時,再考慮 Class Library 與單向 Project Reference。
- Class Library 與 DLL 不等於獨立部署;只有存在獨立發布、擴充、故障隔離、版本或回復需求時,才評估新增可執行程式或部署產物。
- 新增 Assembly 前,列出公開契約、Port、Adapter、Mapping、共享型別與同步點;沒有實際 Consumer 的抽象不得加入。
- 拆分後保留必要的行為測試與 Dependency Test,並驗證 HTTP Contract、Database Schema、副作用順序與失敗語意沒有漂移。
- `dotnet publish` 後回報啟動入口與部署產物,不得用 Project 或 DLL 數量宣稱部署獨立。
- 若新增部署產物會帶來觀測、版本、網路失敗與資料一致性成本,卻沒有獨立部署收益,停止物理拆分並保留升級條件。
長期來看,把這類 Policy 抽成可重複使用的 Skill 會更好。現在先用 AGENTS.md 的格式呈現,方便看清楚 Agent 每次做架構決策時需要哪些 Repository 情境。
如果 User 只說「提高獨立性」,Agent 無法知道我們要的是檔案分開、編譯期隔離、不同執行程序,還是獨立部署。
E — Explicit Intent and Boundaries 意圖明確 在這裡要求我先說清楚兩條 Use Case、假設的負責範圍、同一個 API Host、目前沒有獨立資源與故障隔離需求,以及禁止新增第二個 API 部署產物。Agent 仍然可以決定 Class 與 Port 的設計,不能自行擴張問題。
把 EF Entity 搬到另一個 Assembly、在 Port 與 Adapter 之間新增資料形狀,或重新接上 Dependency Injection,都可能悄悄改變查詢、Tracking、Transaction 與 Cancellation 行為。
N — Non-Surprising Behavior 符合預期 的驗收不能停在 Build。六份候選都要重新驗證 HTTP Contract、SQLite Schema、Outbox 狀態、Atomic Claim、lost ACK、Retry 與副作用順序。架構可以換形狀,系統已承諾的答案不能跟著換掉。
回到標題,拆成多個 Project 只代表 Build Graph 出現更多節點,不能一次證明四種獨立性都成立。
這次 Lifecycle 與 Delivery 已取得較清楚的 Use Case 與 Development 邊界:兩條流程可以分開閱讀、測試與呼叫,修改責任也比較容易辨認。它們仍共用 DbContext、Entity、Composition Root 與同一個 Project,因此沒有編譯期強制隔離。
Operation 與 Deployment 則完全沒有被拆開:HTTP 與 Worker 仍在同一個 API Host,所有 DLL 也跟著同一份 Publish 產物發布。正因為目前沒有不同套件、穩定跨小組契約、資源隔離或獨立發布需求,原始碼 Run 01 已經足夠。
Assembly 候選保留成下一階段的升級方案。等反向引用開始發生,或編譯期契約真的有價值時,再支付 Port、Adapter、Mapping 與額外 Project 的成本。
明天會接著談〈架構邊界〉。知道自己要保護哪一種獨立性之後,下一個問題就是:這條邊界現在就要畫成實體結構,還是先保留能升級的位置,等真正的變更壓力出現再處理?