安安~我是ChiYu~
昨天談完元件原則後,Work Item API 已經形成三個清楚的責任區域:API 處理 HTTP、資料與通知流程,WorkItems.ServiceLevel 保存共用的 SLA 規則,Reporter 則是只使用 SLA 規則的獨立 Consumer。
但這個結構不是某一天突然畫出來的。第二個 Actor、通知 Provider 與 Reporter 依序出現後,設計才一步一步長成現在的樣子。
這讓我想做一個反方向的比較:
如果一開始就把這三項需求全部交給 AI,和每次只加入當下已知需求相比,兩邊遇到下一項未知需求時,修改方式會有什麼差別?
結果不像「一次做完一定過度設計、逐步設計一定比較精簡」這麼整齊。三份 Big-bang 候選都通過行為 Gate,也都停在三個 Production Project;持續設計路線的累積 Diff 反而更大。真正的差異,要等兩邊都不知道的背景 Worker 出現後才看見。
Big-bang 代表版本讓 Controller 與 Worker 直接共用具體 Processor,修改檔案較少;持續設計版本則在第二個 Production 入口真的出現後,建立 IOverdueWorkItemProcessor,讓 Controller、Worker 與 Worker 測試替身共同使用。我最後接受介面版本,不是因為它用了較少 Token,而是契約、兩個正式 Consumer 與隔離測試接縫都已經成立。
今天要比較的不是哪一條路線永遠比較好,而是需求揭露順序如何影響抽象出現的時機,以及下一項未知需求進來時,現有設計要付出什麼修改與驗證成本。
《無瑕的程式碼 第二版》的〈持續設計〉把設計放回每一次修改。專案開始時會做設計,需求、測試與回饋出現後,設計也要繼續調整。
Continuous Design(持續設計)不是不做事前思考,而是不把尚未出現的 Consumer、改變原因與部署方式直接生成成 Production Code。已知風險仍要先處理;未知未來則等到有真實壓力時再決定抽象。
書中以 Four Cs 觀察設計品質:
| Four Cs | 白話理解 | 檢查設計時先看什麼 |
|---|---|---|
| Clarity(清晰性) | 讀者能不能直接理解意圖 | 名稱、公開入口、依賴方向與執行路徑 |
| Conciseness(簡潔性) | 概念是否足夠,而且沒有多餘結構 | 每個 Interface、Class 與 Project 是否已有真實用途 |
| Confirmability(可確認性) | 結果能不能可靠、重複地驗證 | 行為 Oracle、測試、失敗訊息與可重現狀態 |
| Cohesion(內聚性) | 會因相同原因改變的程式碼是否放在一起 | Actor、責任、共同修改原因與下一項需求的落點 |
替 Use Case 抽出介面,可能讓多個入口共用的能力更明確,也方便隔離測試;同時會增加一個檔案與一次跳轉。Four Cs 不會替某一種架構背書,而是讓這筆交換能被說清楚。
我也保留一條界線:Public API、Database Schema、資料移轉、權限、金流、跨服務契約與不可逆外部操作,一旦選錯就很難回復。這些已知風險仍要先完成相稱的事前設計,不能拿「持續演進」當成延後思考的理由。
現在的程式碼仍要靠名稱、契約、測試與依賴方向表達意圖。Git 歷程可以補充抽象出現的原因,不能替難懂的 Code 善後。
Uncle Bob 在近期訪談裡分享 Heavy Upfront Planning 的經驗:Agent 依照完整計畫一路往下做時,只要中途出現原先沒想到的細節,後續步驟就可能建立在錯誤前提上,最後得停下來重寫。
他用一棟「每次改動只要一美元」的房子做思想實驗。若移動牆面、樓梯與廚房都非常便宜,先做一小段、實際走走看,再依回饋調整,可能比先花大筆成本追求完美藍圖更划算。AI 確實降低了產生與修改 Code 的成本,也讓短週期更值得考慮。
不過,這個比喻沒有讓規格消失。他的流程仍會先把小範圍需求整理成 Gherkin 與 QA Procedure;被質疑的是一次規劃完所有細節,而不是需求、契約與驗收條件本身。Public API、Schema、權限、金流與不可逆副作用的修改成本也沒有接近一美元。
因此兩條路線都有規劃,差別在批次:第一條一次交付三項已知需求,第二條每次只加入一項真實需求。我要看的是,哪一條路線讓抽象的來歷更容易追查,也能在未知 Worker 出現時合理調整。
Four Cs 用來檢查 Code 與設計結果;CLEAN 則規範我如何提供情境、限制範圍、保存實據並守住既有行為。
今天主要使用兩項 CLEAN 原則:
C — Context-Aware Code 情境感知:把目前真實存在的需求、Consumer 與 Repository 狀態交代清楚,也把未知項目明確標成未知。A — Auditable by Evidence 實據可審:讓 Prompt、Diff、Commit、Tag 與驗證結果都能對回同一項需求,抽象為什麼出現可以被追查。兩條路線都從 Day 14 接受的簡單設計版本開始:
git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git switch --detach day-14-simple-design
起點 Commit 是:
fb22f445f383c7977dfac71f35cc028b0ca53e00
當時逾期流程已通過既有行為驗證,規則也足夠簡單、意圖明確;第二個 Actor、第二個 Provider 與 Reporter 都還沒出現。這是三項設計壓力進入 Repository 前,最後一個共同狀態。
| 路線 | Agent 何時取得需求 | 執行方式 | 能回答什麼 |
|---|---|---|---|
| 一次到位式設計(Big-bang) | 第一輪同時取得三項需求 | 從相同起點執行三個全新 Session | 同一批已知需求一次交付時,Agent 會產生哪些結構 |
| 持續設計歷程 | 三項需求依序在 Day 15、16、17 出現 | 沿用系列實際接受的 Commit 序列 | 每個抽象首次被需要時,當下有哪些 Consumer 與變更原因 |
這裡的 Big-bang 是本系列對實驗路線的稱呼:User 一次提供目前已知的三項需求,讓 Agent 在同一批修改中完成。它不代表 Agent 已經看見所有未來,更不代表它能預測今天才公開的 Worker。
第一條路線一次取得:
第二條路線則沿用本系列實際走過的歷程:
Day 14 fb22f445 目前只需要表達 SLA 規則
↓
Day 15 ccbc136d 第二個 Actor 出現,分開兩種改變原因
↓
Day 16 5f596a62 第二個通知 Provider 出現,建立 Consumer 所需契約
↓
Day 17 76de5b54 Reporter 出現,只抽出已被共同使用的 SLA 元件
這項比較有一個重要限制。Big-bang 使用三個全新 Session;持續設計路線則來自不同日期的三次實驗,其中包含不同 Prompt、候選比較與人工選擇。因此本文比較的是兩段實際設計歷程,無法把差異全部歸因於需求揭露時間。
完整 Prompt 與控制條件保存在公開 Repository:
兩邊共用與刻意隱藏的資訊如下:
固定:
- 相同 Repository 起點
- Codex GPT-5.6-SOL-HIGH
- 相同既有 HTTP Contract、Database Schema 與行為 Gate
Big-bang 第一階段公開:
- 第二個 Actor
- 第二個 Provider
- Reporter Consumer
第一階段刻意不公開:
- 背景 Worker
- 並行控制
- Outbox
- Idempotency
三個 Session 的起點、Prompt、模型與主流程驗證相同。我先確認需求是否完成,再比較類別、測試與 Provider 接線的差異。
| Run | 主流程驗證 | Production 邊界 | 修改範圍 | 實際設計差異 |
|---|---|---|---|---|
| 01 | 通過 | API、ServiceLevel、Reporter | 21 個檔案 | 使用 ServiceLevelNotificationRule,Reporter 邏輯放在 ReporterApplication,並建立獨立的 ServiceLevel 測試 Project |
| 02 | 通過 | API、ServiceLevel、Reporter | 19 個檔案 | 使用 ServiceLevelEscalationRule,Reporter 邏輯直接留在 Program,相關測試集中於 WorkItems.Api.Tests |
| 03 | 通過 | API、ServiceLevel、Reporter | 22 個檔案 | 使用 ServiceLevelEscalationPolicy,另外拆出 QueuedNotificationGateway 與 ReporterApplication,Provider 選擇則放進設定檔 |
三份候選都能依 Lock File 還原套件並完成 Release Build,格式也符合規範。完整測試與 API 基本流程守住既有行為,Reporter 案例則確認第二個 Consumer 可以獨立執行。它們也都停在三個 Production Project,沒有為了「未來擴充」繼續拆出一長串架構層。
所以這次結果不支持「Big-bang 一定會過度設計」。需求、禁止事項與行為 Gate 足夠清楚時,Agent 一次取得多項真實需求,也能停在合理範圍。不過,完整需求沒有讓設計收斂成唯一答案,三份候選對類別、測試與 Provider 接線仍有不同選擇。
後續 Worker 壓力選用 Run 02。三份候選都保留完整能力並通過相同 Gate,而 Run 02 的修改範圍最小,適合當下一階段代表起點。這項選擇只服務本次壓力測試,不代表 Run 02 是 Big-bang 的平均輸出或普遍最佳架構。
讀者可以直接切換:
git switch --detach day-18-formal-big-bang-run-02
從共同起點走到相近功能終點,持續設計路線累計修改 23 個檔案,增加 730 行、刪除 41 行;Big-bang Run 02 修改 19 個檔案,增加 474 行、刪除 24 行。
持續路線跨越三次不同日期的實驗,也包含不同測試與人工選擇,所以這些數字不能直接用來宣稱持續設計比較便宜。這次它的累積 Diff 反而更大。
持續路線真正留下的優勢,是每個抽象都能對回第一次需要它的壓力:
| Commit | 當時新增的真實情境 | 因此留下的設計 |
|---|---|---|
ccbc136d |
派工管理與服務等級由不同 Actor 維護 | 分開兩種升級規則與改變原因 |
5f596a62 |
Demo 與 Queued 都要服務同一個逾期流程 | 由 Consumer 定義通知契約,Provider 反向實作 |
76de5b54 |
Reporter 只需要 SLA,不需要整個 API | 抽出 WorkItems.ServiceLevel,並停止拆更多 Project |
Commit 與 Tag 可以重現當時狀態,也能查出抽象第一次出現的原因。現在的 Code 仍要靠類別名稱、依賴方向、公開契約與測試說明責任;讀者不該先考古 Git History,才知道設計在做什麼。
兩條路線抵達相近功能終點後,我才公開相同的新需求:新增 ASP.NET Core BackgroundService,讓背景排程與既有 HTTP Action 共用逾期處理。
Worker 預設停用,啟用後每 60 秒觸發一次逾期流程,並把 Host 停止時的 Cancellation 往下傳遞。
我把它留到第二階段,讓兩個版本先在完全不知道這項需求的情況下完成。Worker 出現後,才比較:
這一階段只測第二個執行入口。Lock、Lease、Concurrency Token、Transaction、Outbox 與 Idempotency 都刻意不做,避免把「如何共用 Use Case」和「如何避免兩個流程重複處理」混成同一項實驗。
兩份候選最後都讓 Controller 與 Worker 進入同一份 ProcessAsync,沒有各自複製查詢、升級判斷、通知、統計與儲存流程。
Big-bang Run 02 原本已有 OverdueWorkItemProcessor。加入 Worker 後,Controller 與 OverdueProcessingWorker 直接依賴同一個具體類別:
WorkItemsController ──┐
├──> OverdueWorkItemProcessor.ProcessAsync
Background Worker ───┘
這個版本只修改 4 個檔案,新增 281 行。Worker 測試啟動 ASP.NET Core 測試 Host,接上測試資料庫與既有通知替身,驗證 Worker、DI、Database 與逾期流程組裝後能否一起運作。代價是每次測試都要建立較完整的環境。
它沒有為第二個入口再建立介面,呼叫路徑也少一層跳轉。若團隊本來就偏好完整整合驗證,這是一個合理答案。
持續設計終點原本只有 Controller 使用 Processor。Worker 成為第二個入口後,Agent 新增 IOverdueWorkItemProcessor,再讓兩個入口都依賴這份 Use Case 契約:
WorkItemsController ──┐
├──> IOverdueWorkItemProcessor
Background Worker ───┘ │
└──> OverdueWorkItemProcessor
持續設計版本修改 8 個檔案,增加 187 行、刪除 3 行。Controller、Processor、DI 與測試都要改接介面,所以觸及檔案較多;Worker 測試不必啟動完整 API 與資料庫,因此新增測試程式碼反而較少。
這個介面目前有兩種實際用途:Controller 與 Worker 透過它共用 Use Case;Worker 測試則注入 RecordingOverdueWorkItemProcessor,只記錄呼叫次數與 Cancellation。這是一個已被正式入口與測試使用的接縫,不是替想像中的第三種 Processor 留空位。
| 版本 | Production 共用方式 | Worker 測試方式 | 目前代價 |
|---|---|---|---|
| Big-bang Run 02 | 兩個入口依賴具體 Processor | 以完整 API、Database 與通知替身做整合驗證 | 路徑直接,但測試需要較完整的執行環境 |
| 持續設計終點 | 兩個入口依賴 Use Case 介面 | 注入記錄型測試替身,隔離 Worker 生命週期 | 多一層介面與 DI 接線 |
兩種版本都通過相同主流程 Gate。檔案數與新增行數只描述修改成本,最後仍要看新增抽象是否有真實 Consumer,以及測試要回答哪一個問題。

圖:持續設計讓背景 Worker 成為第二個真實使用情境後再建立介面;Big-bang 是否適合,仍取決於需求不確定性。
兩份候選都能從 HTTP 與背景排程走到同一份 ProcessAsync,沒有把業務流程藏在 Controller 或 Worker 裡。
具體 Processor 少一次跳轉,看見類別就能找到實作;Use Case 介面則直接命名兩個入口共同需要的能力,再由 DI 指向實作。前者直接,後者把契約寫得更明確,兩者目前都說得通。
Big-bang 版本沒有新增介面,適合實作唯一、專案不需要隔離替換的情境。
持續設計版本的介面已被 Controller、Worker 與測試替身使用,因此不是沒有 Consumer 的預先抽象。若未來這些用途消失,或每次修改都必須同時更動介面與實作,這層抽象仍應重新檢查。
Big-bang 版本的測試會走過 DI、Database、通知與 Worker 的完整接線;持續設計版本則以測試替身隔離 Worker 生命週期。
整合測試回答組裝後能不能一起運作;隔離測試則能更快指出排程次數、預設開關或 Cancellation 哪一項行為失敗。Confirmability 看的是回饋是否可信、失敗是否對得上需求缺口,不是測試總數。
兩份候選都把逾期查詢、升級規則、通知、統計與儲存留在 Processor;Worker 只負責排程週期、Scope、Cancellation 與錯誤紀錄。
逾期規則改變時,主要修改 Processor;執行週期或 Host 生命週期改變時,主要修改 Worker。這種修改落點比「都放一個 Project」或「每層拆一個 Project」更能說明內聚性。
持續設計要多次讀取 Repository、執行 Gate 並做出接受決策,不能預設它會省 Token。
這次 Worker 單次實驗中,持續設計版本使用 12,231 Output Token、23 次工具呼叫,約 330 秒完成;Big-bang 代表版本使用 19,756 Output Token、31 次工具呼叫,約 654 秒。
兩個起點的結構與測試不同,而且各自只跑一次。這筆資料只能描述本次 Worker 修改的成本,不能把 Token 下降歸因於持續設計。若少用 Token 的候選改變行為、漏掉失敗路徑或留下無法說明的抽象,它仍不是較好的答案。
C — Context-Aware Code 情境感知 要求我完整提供已確認的需求、風險與 Repository 規則,並把尚未出現的 Consumer 與功能標成未知。
Big-bang 路線同時取得三項真實需求,因此一次處理並沒有違反 C;持續設計路線每一步只取得當時已知情境,也同樣成立。兩者都不能用「一般架構通常會需要」替 Repository 自行補完未被證實的未來。
A — Auditable by Evidence 實據可審 要求 Prompt、Diff、Commit、Tag、Tests 與主流程驗證可以對回同一項需求。這樣才能查出某個 Interface 或 Project 何時出現、當時解決什麼問題,以及後續是否還有存在理由。
本系列把能獨立 Build、測試與切換的 Commit/Tag 狀態稱為 Git Checkpoint。這是文章使用的實驗名稱,不是 Git 內建功能。
git revert 會建立新的 Commit,反轉指定 Commit 的原始碼 Patch;它不會收回已寄出的通知、完成的 Database Migration 或部署。後續 Commit 若依賴被撤回的結構,Revert 也可能衝突。因此回退後仍要重新 Build、Test,外部狀態則要另外處理。
L — Localized Change 局部變更 在這篇只作為修改範圍的輔助檢查:Worker 需求是否只新增第二個入口,還是順便重做 SLA、Provider 或 Reporter?檔案數與行數只是線索,不足以單獨選出答案。
| 情境 | 優先方向 | 判斷理由 |
|---|---|---|
| 需求仍在變,每一步都有可靠測試且可以安全回退 | 持續設計 | 讓抽象跟著真實 Consumer 與變更原因出現 |
| Public API、Schema、資料移轉、資安、權限、金流或不可逆副作用 | 先完成必要事前設計 | 邊界選錯後的回復成本遠高於短時間分析 |
| 可丟棄 Prototype,只想驗證技術可行性 | 可以一次生成 | 重點是快速取得答案,不是長期維護結構 |
| 已有成熟模板、固定 Contract 與完整驗證器 | 可以一次套用 | 未知因素少,Agent 主要執行機械化轉換 |
| 一次修改混入多項無法獨立驗證的需求 | 先切小再做 | 失敗原因、審查與回退範圍已經混在一起 |
我會用三項條件選擇 Agent 的工作節奏:需求是否穩定、失敗是否包含不可逆風險,以及目前的驗證能力能不能接住修改。
我先用 AGENTS.md 的格式整理 Continuous Design Policy,讓讀者可以直接參考。這裡是文章中的示範,尚未宣稱它已寫入公開 Demo。系列後段會再把成熟 Policy 抽成 Skill,不必一直擴充 Repository Instruction。
## Continuous Design Policy
- 先列出目前已證實的需求、Consumer、Actor、固定契約與未知項目,
不得把假想未來直接生成為 Production Interface、Project、Provider 或架構層。
- 新抽象必須回報首次需要它的需求、實際 Consumer、替代方案與停止理由;
沒有真實用途時,優先保留較便宜的函式、類別、Namespace 或具體實作。
- 每個演進步驟必須保持可 Build、可測試、可審查,
不得為追求小 Commit 留下無法執行或語意錯誤的中途版本。
- 評估結果時,以 Clarity、Conciseness、Confirmability、Cohesion 說明取捨;
不得用檔案數、行數、Project 數、Coverage、Commit 數或 Token 單獨判定品質。
- 需求仍在演進、每一步可驗證且可安全回退時,優先採用持續設計。
- Public API、Database Schema、資料移轉、資安、權限、金流、
跨服務契約或不可逆副作用,必須先完成與風險相稱的事前設計。
- 可丟棄 Prototype、成熟固定模板或具有獨立完整驗證器的機械化任務,
可以一次生成,但必須標示產物能否直接成為長期維護的 Production Code。
- 完成後回報 Prompt、起點、Diff、Commit/Tag、Gate、可回退範圍、
外部副作用、Token/Tool Call 與尚未處理的風險。
這份示範 Policy 沒有把所有任務都固定拆成小步驟。User 與 Agent 仍要依需求穩定度、不可逆風險與驗證能力,選擇持續演進、必要事前設計或一次完成,再用 Four Cs 驗收結果。
本系列接下來使用持續設計路線的 Worker 版本:
git switch --detach day-18-continuous-design
Commit 是:
be987152555b651532e4ba68ba1029aa777fb40e
回到標題,一次完成三項需求的版本並沒有失控,它用較少檔案讓兩個入口直接共用具體 Processor;逐步設計的版本也沒有自動比較小,它的累積 Diff 反而更大。兩邊真正不同的,是 Worker 出現時,是否已經有足夠理由建立 Use Case 契約。
我接受持續設計版本,因為現在的 Code 能直接看出 Controller 與 Worker 共用同一份逾期 Use Case。IOverdueWorkItemProcessor 已被兩個 Production 入口使用,也真的形成 Worker 隔離測試的接縫;Commit 歷程則補充說明,介面是在第二個入口出現後才建立。修改檔案數、測試總數與較低 Output Token 都只描述成本,不是接受條件。
Big-bang 的具體 Processor 依賴仍是合理選擇。如果專案只有單一實作,而且團隊本來就以完整整合測試驗證 Worker,增加介面只會多一次跳轉,我會保留具體依賴。
持續設計幫這個 API 承接了第二個入口,卻沒有自動解決兩個入口同時執行的問題。兩個版本都留下同一個缺口:Smoke Test 對兩筆已標成逾期的 Work Item 再執行一次時,processedCount=0,流程卻仍對兩筆資料各嘗試一次通知。共用 Use Case 解決規則重複,還沒有解決副作用重複。
明天我會用兩個獨立執行流程同時處理同一筆逾期項目,實際重現競態,再拆開 Concurrency Control、Transaction、Outbox 與 Idempotency 各自能守住的失敗窗口。