安安~我是ChiYu~
昨天,我們處理了 HTTP 與背景 Worker 同時選中同一筆 Work Item、進而重複送出通知的問題。資料更新、通知傳送、失敗重試與取消路徑,也都有對應測試。
從行為價值來看,昨天的需求已經完成。不過,測試全綠只說明系統現在能運作,還沒有回答下一次需求進來時,哪些責任會被迫一起修改。
所以今天我先建立兩份行為相同、結構不同的程式碼:一份保留整合式 Dispatcher,另一份把不依賴 EF Core 與 Gateway 的決策移到具體 Policy。第一階段完成時,兩個方向都有合理停止點,我也刻意沒有公開下一題。
接著,我才交付事前保密的重試矩陣:Escalation 第一次失敗等 5 分鐘,之後等 30 分鐘;Overdue 第一次失敗等 15 分鐘,之後等 60 分鐘。
六次壓力實驗都通過相同行為 Gate。整合式候選的第二次 Diff 比較短;Policy 分離式的三次執行,則都把重試矩陣放進既有的 NotificationDispatchPolicy。本系列最後採用 Policy Run 03,因為「通知種類 × 嘗試次數」已經形成真實變化軸,而且過時的 ReleaseLease 也被改成更準確的 ScheduleRetry。
這不是宣布 Policy 永遠比較好。如果重試矩陣沒有出現,規則少、只有一個 Consumer,整合式 Dispatcher 的零修改結果反而更精簡。今天要驗收的,是哪一份結構能讓已經發生的變化沿著清楚邊界落下,而不是哪一份看起來比較有架構。
Clean Code 進入架構層次後,檢查範圍會從名稱、函式與類別,延伸到 Use Case、背景 Worker、資料庫與外部服務之間的責任。
〈軟體的兩種價值〉把這件事分成兩個面向:
行為價值很容易看見:API 回傳正確、資料成功儲存、通知有送出、測試通過。結構價值則通常要等下一項需求進來,才能從實際修改路徑判斷:
如果用生活中的例子來說,行為價值像是確認今天按下開關,電燈會亮;結構價值則是下次想多裝一個開關時,是否得拆掉整面牆。
檔案數與抽象數量都只能當線索。多種責任可能被塞在同一個檔案裡;Interface、Strategy 與 Framework 也可能只是替尚未出現的變化預付成本。設計層次真正要看的,是已經出現的變化能不能沿著清楚責任發生。
整合式 Dispatcher 與 Policy 分離式 Dispatcher,是我為了觀察結構價值而設計的兩種對照,不是〈軟體的兩種價值〉列出的固定解法。實驗分成兩個階段:
第一階段不公開重試需求。Agent 若事先知道下一題,很可能直接替那項規則準備結構;這樣只能觀察預先設計的效果,無法知道原本結構面對未知變更時會怎麼改。
我選擇重試時間作為壓力,是因為兩份候選都必須修改相同的 Outbox 欄位、時間資格與 Atomic Claim;真正拉開差異的地方,會集中在重試矩陣屬於 Dispatcher 的 I/O 流程,還是已形成獨立變化方向的純決策。
flowchart LR
B[昨天確認的並行行為基準] --> I[整合式 Dispatcher]
B --> P[Policy 分離式 Dispatcher]
I --> R1[加入相同重試規則]
P --> R2[加入相同重試規則]
R1 --> C[比較修改位置與結構成本]
R2 --> C
實驗起點是昨天接受的並行版本:
388f0af06987056516a7fbc9b461c107127b067c
day-19-concurrency-race
想直接查看起點,可以執行:
git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git switch --detach day-19-concurrency-race
完整 Prompt、原始輸出、每份候選與主流程驗證保存在公開實驗資料。
| 實驗條件 | 固定方式 |
|---|---|
| Agent | Codex,gpt-5.6-sol,Reasoning Effort:high |
| 起點 | 相同的 day-19-concurrency-race |
| 工作階段 | Ephemeral Session,每次建立全新工作階段,不沿用前一次對話 |
| 工作目錄 | 各自使用獨立 Git Worktree |
| Repository 規則 | 相同的 Repository Instruction |
| 驗證 | 相同的 Build、Tests、Format、Diff 與 API 基本流程檢查 |
| 第一階段唯一規劃變因 | 保留整合式 Dispatcher,或分離純決策 Policy |
第一階段兩個方向各執行一次;第二階段再從兩份結構各跑三次。每組三次用來觀察修改方向是否穩定,仍只是小樣本,不能當成統計結論。
這組結果屬於可重跑的工程觀察。模型、起點、需求與驗證方式固定,Agent 的探索順序與工具使用仍有差異,因此不能把結果解讀成嚴格的單一變因實驗。
起點的 NotificationOutboxDispatcher 同時負責:
這些責任可以有兩種合理解讀:
| 結構方向 | 怎麼看目前責任 | 當下優點 | 先支付的成本 | 什麼情況會成立 |
|---|---|---|---|---|
| 整合式 Dispatcher | 掃描、Claim、傳送與狀態更新共同維護一條完整流程 | 跳轉少、結構直接、沒有新增概念 | 純決策與 I/O 留在同一類別 | 只有一個 Consumer,規則少且一起改變 |
| Policy 分離式 Dispatcher | Lease、路由與完成狀態是可以脫離 EF Core 與 Gateway 的純決策 | 規則可獨立閱讀與測試 | 多一個類別、依賴與跳轉 | 決策有獨立變化方向,或會被多處重用 |
第一階段的共同條件相同,只有結構方向不同:
共同條件:
- 保留 HTTP Contract、SQLite Schema、並行、lost ACK、取消與冪等行為。
- 不新增尚未出現的 Provider、Strategy 或部署層。
- 完成後執行相同驗證,並說明停止理由。
整合式方向:
- 先判斷現有流程是否真的需要拆分。
- 沒有獨立變化證據時,允許保持零修改。
Policy 分離式方向:
- 將不需要 EF Core、Gateway 或時間來源即可回答的決策,移到具體 Policy。
- 沒有第二個實作需求時,不建立 Interface。
完整 Prompt 留在公開實驗資料,正文只保留會影響結果的條件,避免操作細節蓋過核心問題。
整合式候選讀完程式碼與測試後,選擇不修改正式程式碼。它看到 Lease、通知路由、完成狀態與 lost ACK 目前都跟著同一條 Dispatch 流程改變;Repository 也沒有第二個 Consumer 或獨立變化規則。此時繼續拆分,只會增加依賴與閱讀跳轉。
Policy 分離式候選則新增具體的 NotificationDispatchPolicy,但沒有為了形式再建立 Interface:
public sealed class NotificationDispatchPolicy
{
private static readonly TimeSpan LeaseDuration =
TimeSpan.FromMinutes(1);
public DateTimeOffset CalculateLeaseExpiration(
DateTimeOffset claimUtc) =>
claimUtc.Add(LeaseDuration);
public NotificationDeliveryRoute SelectDeliveryRoute(
string notificationKind) =>
notificationKind == "Escalation"
? NotificationDeliveryRoute.Escalation
: NotificationDeliveryRoute.Overdue;
public NotificationDispatchCompletion DecideCompletion(
bool acknowledged) =>
acknowledged
? NotificationDispatchCompletion.MarkSent
: NotificationDispatchCompletion.ReleaseLease;
}
Dispatcher 依賴具體 Policy;Policy 不依賴 EF Core、Gateway 或 TimeProvider。
第一階段得到兩個都說得通的結果。整合式候選留下零 Production Diff,表示「停止重構」在當下成立;Policy 分離式則隔離純決策,而且沒有再堆出 Interface 或新架構層。真正的結構價值,要等新規則實際進場才看得見。
兩份結構固定後,我才交付相同的新需求:
| 通知種類 | 第一次失敗後 | 第二次及後續失敗後 |
|---|---|---|
Escalation |
等待 5 分鐘 | 等待 30 分鐘 |
Overdue |
等待 15 分鐘 | 等待 60 分鐘 |
Retry Policy 通常要回答哪些失敗可以重試、何時重試、最多幾次,以及最後仍失敗時如何處理。今天只聚焦排程時間。Backoff 則是在下一次嘗試前先等待,避免外部服務尚未恢復時被立即連續呼叫。
這次實作的是持久化業務排程:下一次可處理時間會寫進 SQLite,Host 重啟或換另一個 Dispatcher 接手後仍能延續。
Microsoft 的 Retry Pattern 主要處理單次遠端呼叫遇到的短暫性錯誤,還要一起考慮錯誤類型、嘗試次數、等待間隔與冪等性。兩者處理的生命週期不同。
這項需求還有幾個不能省略的邊界:
AttemptCount 在 Atomic Claim 時增加,所以第一次失敗時的值是 1。currentUtc == NextAttemptAtUtc 時可以開始處理。OperationCanceledException 必須向外傳遞並保留 Lease,不能被當成一般失敗。TimeProvider 是 .NET 的時間來源抽象。正式程式用它取得目前時間,測試則替換成 FakeTimeProvider,直接把時鐘往前推,不必真的等待 5、15 或 60 分鐘,也能驗證邊界前不可執行、到達邊界即可執行。
第二階段的關鍵 Prompt 可以濃縮成:
- 從指定的第一階段結構開始,不得重新選擇起點。
- 先新增時間邊界測試,並確認舊程式會失敗。
- 再加入最小正式程式碼,使相同測試轉綠。
- 保留既有 API、SQLite、並行、lost ACK、取消與冪等行為。
- 不得以 Task.Delay 代替持久化的 NextAttemptAtUtc。
- 完成後回報規則落點、修改範圍、驗證結果與停止理由。
兩種起始結構各跑三次。舊程式在尚未到達 NextAttemptAtUtc 時,仍然掃描訊息、取得處理權並呼叫 Gateway,因此六次都得到相同 RED:
Expected gateway attempts: 0
Actual gateway attempts: 1
這個失敗直接對應需求缺口:還沒到重試時間,舊 Dispatcher 就已呼叫通知服務。保留 RED 也能確認新測試真的攔得住舊行為;若 Agent 同時改完 Tests 與 Production,最後只剩綠燈,就無法證明測試曾經看見缺口。
不論規則放在哪裡,兩種結構都需要替 Outbox 保存下一次可處理時間:
/// <summary>
/// 取得下一次允許 Dispatcher 處理的 UTC 時間。
/// </summary>
public DateTimeOffset NextAttemptAtUtc { get; private set; }
新訊息建立時,NextAttemptAtUtc 等於 CreatedAtUtc,因此可以立即處理。
掃描條件要排除尚未到時間的訊息:
message.Status == "Pending" &&
message.NextAttemptAtUtc <= scanUtc &&
(message.LeaseToken == null ||
message.LeaseExpiresAtUtc <= scanUtc)
真正取得處理權的 Atomic Claim,也要再次檢查:
message.Id == messageId &&
message.Status == "Pending" &&
message.NextAttemptAtUtc <= claimUtc &&
(message.LeaseToken == null ||
message.LeaseExpiresAtUtc <= claimUtc)
掃描只是列出當下看來符合資格的候選;Atomic Claim 才在資料庫更新那一刻重新確認資格並取得處理權。兩個動作之間,狀態、Lease 或下一次處理時間都可能被另一個 Dispatcher 改變,所以相同時間條件必須在 Claim 時再檢查。
ExecuteUpdateAsync 會把 Claim 條件轉成資料庫更新的 WHERE,再以受影響列數判斷目前 Dispatcher 是否取得處理權。只在掃描時檢查,單一流程可能通過,並行時仍可能繞過時間限制。
三次整合式實驗都採用相同方向:新增私有函式,讓 Dispatcher 自己計算等待時間。
private static TimeSpan GetRetryDelay(
string notificationKind,
int attemptCount) =>
(notificationKind, attemptCount) switch
{
("Escalation", 1) => TimeSpan.FromMinutes(5),
("Escalation", _) => TimeSpan.FromMinutes(30),
(_, 1) => TimeSpan.FromMinutes(15),
_ => TimeSpan.FromMinutes(60)
};
一般失敗時,Dispatcher 計算下一次時間,再存回 SQLite:
var nextAttemptAtUtc = timeProvider.GetUtcNow().Add(
GetRetryDelay(
message.NotificationKind,
message.AttemptCount));
await ScheduleRetryAsync(
messageId,
leaseToken,
nextAttemptAtUtc,
cancellationToken);
在單一 Dispatcher、規則固定的小型服務裡,這個版本很合理。四格規則只有一份,方法名稱也能表達意圖,閱讀時不必跳到另一個類別。

圖:整合式 Dispatcher 修改短、跳轉少,但重試決策與 EF/Gateway 協調留在同一類別。
三次 Policy 分離式實驗都把重試矩陣加入第一階段已存在的 NotificationDispatchPolicy,沒有再新增 Retry Service、Strategy Interface 或新架構層。
Policy Run 03 最後留下這段實作:
public DateTimeOffset CalculateNextAttemptAtUtc(
string notificationKind,
int attemptCount,
DateTimeOffset failureUtc)
{
var isFirstAttempt = attemptCount == 1;
var retryDelay = SelectDeliveryRoute(notificationKind) switch
{
NotificationDeliveryRoute.Escalation
when isFirstAttempt =>
EscalationFirstRetryDelay,
NotificationDeliveryRoute.Escalation =>
EscalationSubsequentRetryDelay,
NotificationDeliveryRoute.Overdue
when isFirstAttempt =>
OverdueFirstRetryDelay,
_ => OverdueSubsequentRetryDelay
};
return failureUtc.Add(retryDelay);
}
重試矩陣直接重用 SelectDeliveryRoute。通知種類只在 NotificationDispatchPolicy 解析一次,Dispatcher 不必再維護另一份 NotificationKind 判斷。
Dispatcher 只負責取得失敗時間、詢問 Policy,再把結果存回 SQLite:
var nextAttemptAtUtc =
dispatchPolicy.CalculateNextAttemptAtUtc(
message.NotificationKind,
message.AttemptCount,
timeProvider.GetUtcNow());
await ScheduleRetryAsync(
messageId,
leaseToken,
nextAttemptAtUtc,
cancellationToken);
兩種依賴方向可以簡化成:
flowchart LR
subgraph Integrated["整合式候選"]
ID[NotificationOutboxDispatcher]
IR[私有重試矩陣]
ID --> IR
ID --> IDB[(EF Core / SQLite)]
ID --> IG[Notification Gateway]
ID --> IT[TimeProvider]
end
subgraph Separated["Policy 分離式候選"]
SD[NotificationOutboxDispatcher]
SP[NotificationDispatchPolicy]
SD --> SP
SD --> SDB[(EF Core / SQLite)]
SD --> SG[Notification Gateway]
SD --> ST[TimeProvider]
SP --> SR[Route / Lease / Retry / Completion]
end
Policy 不知道 EF Core、Gateway 與 TimeProvider。它只接收已取得的事實,回傳決策結果。

圖:Policy 分離式多一個概念,換來通知路由、Lease、Retry 與 Completion 的單一決策落點。
Agent 完成後,主流程逐一重跑 Release Build、完整 Tests、Format、Diff Check、套件弱點掃描與 API Smoke,確認六份候選都維持既有行為與新的時間邊界。
| 比較面向 | 整合式 Dispatcher | Policy 分離式 Dispatcher |
|---|---|---|
| 既有行為測試 | 全部通過 | 全部通過 |
| 新的時間邊界測試 | 全部通過 | 全部通過 |
| 重試規則位置 | Dispatcher 私有函式 | 純 Policy |
| I/O 與規則 | 留在同一類別 | 分開 |
| 第二次修改量 | 較短 | 較大 |
| 閱讀方式 | 跳轉少,完整流程集中 | 可單獨閱讀規則,但多一個概念 |
| 未來新增規則 | 繼續修改 Dispatcher | 優先修改 Policy,Dispatcher 保持 I/O 編排 |
第二次 Diff 的確是整合式較短,但行數沒有說明規則落點。整合式把 EF 查詢、Gateway 協調與重試矩陣留在同一個 Dispatcher;Policy 分離式則把「通知種類 × 嘗試次數」集中在不依賴外部技術的決策物件。
Policy 分離式先支付一個類別、一項依賴與一次閱讀跳轉;重試矩陣出現後,新規則直接落進既有 Policy,這筆成本才開始產生用途。
完整測試數、每次程式碼差異與主流程驗證都保留在公開證據,正文不拿測試總數或行數替結構排名。
Policy 分離組的平均 Input/Output Token 較低,執行時間與工具呼叫次數卻比較高。每組只有三次,而且 Agent 的探索、測試輸出與重跑路徑不同,不能因此宣稱「拆出 Policy 可以固定節省多少 Token」。
這次能直接確認的是:三個 Policy 分離式 Run 都把重試矩陣放進既有 NotificationDispatchPolicy,修改落點相當一致。這不等於 Agent 一定理解得更快,也無法讓錯誤抽象突然變成好設計。
ReleaseLease 也完成改名本系列接續 policy-separated-pressure-run-03:
2ae2de5
day-20-two-values
可以直接切換:
git fetch --tags
git switch --detach day-20-two-values
我接受 Run 03 的理由,都能從目前 Repository 找到證據:
ReleaseLease 改成更符合意圖的 ScheduleRetry。代價也很明確:累積 Diff 較大,閱讀流程時多一次類別跳轉。這次我願意支付,因為重試矩陣已經重用既有路由判斷,而且只有一個規則來源。
這項決策只適用於目前的 Work Item API、這組重試矩陣與既有通知流程。
| 專案情境 | 比較適合的方向 | 判斷理由 |
|---|---|---|
| 單一 Dispatcher、固定少量規則,近期沒有獨立變化 | 整合式私有函式 | 跳轉少、修改小,規則仍有唯一來源 |
| 通知種類、嘗試次數、SLA 或錯誤類型開始形成矩陣 | 具體 Policy | 純決策能獨立閱讀與測試,I/O 編排保持穩定 |
| 不同租戶或 Provider 需要替換不同重試行為 | 設定物件或 Strategy | 已出現明確替換需求,此時新增抽象才有用途 |
| 只處理一次遠端呼叫的短暫性錯誤 | 平台或 SDK 內建 Retry | 避免自行重做成熟機制,但仍要驗證逾時與冪等性 |
| Provider 不具冪等性,lost ACK 可能造成重複副作用 | 先處理冪等與投遞保證 | Backoff 只能延後重複發生的時間,不能消除重複效果 |
如果重試需求從未出現,而且 Lease、路由與完成狀態仍然一起改變,第一階段保持零修改的整合式候選會更合適。此時先新增 Policy,主要只會多出一項依賴與閱讀跳轉。
老樣子~我把這次選擇條件整理成一份可放進 AGENTS.md 的範例。這裡的 Policy 指寫給 Agent 的 Repository Instruction,和前面的 NotificationDispatchPolicy 程式類別是兩件事。
這份規則是實驗完成後才整理的,沒有回頭放進本次 Prompt,也尚未寫入 API Demo;否則會污染兩種結構的比較。
## Notification Dispatch Structure Policy
- 單一 Dispatcher、規則固定且共同改變時,優先保留具名私有函式,不為展示分層新增 Policy。
- 當通知種類、嘗試次數、錯誤類型或 SLA 形成獨立規則矩陣時,將純決策移到具體 Policy。
- 只有出現兩個以上可替換實作、租戶差異或 Provider 差異時,才考慮 Strategy 與 Interface。
- 單次遠端呼叫的短暫性錯誤,先評估 SDK 或平台提供的 Retry;不得與 Outbox 的持久化排程重複重試。
- Provider 不具冪等性時,先說明重複副作用與投遞保證,Backoff 不得被宣稱為 Exactly Once。
- 掃描資格與 Atomic Claim 必須使用相同的時間、狀態與 Lease 邊界。
- 重構不得改變 HTTP Contract、取消傳遞、lost ACK、Idempotency Key 與既有外部副作用。
- 完成後回報真實變化軸、規則落點、新增依賴、保留的替代方案與停止理由。
等這套判斷流程在更多專案與情境下穩定後,抽成 Skill 會比長期堆在 AGENTS.md 更合適。
第一階段只有單一 Consumer,也沒有獨立重試規則,整合式候選停在零修改是合理結果。Policy 分離式候選則把不依賴 EF Core、Gateway 與時間來源的決策集中起來。
第二階段出現「通知種類 × 嘗試次數」矩陣後,Policy 分離才取得足夠的 Repository 證據。C — Context-Aware Code 情境感知 要我用這些真實使用情境判斷抽象是否值得留下。
第二階段新增 NextAttemptAtUtc,也改變失敗後何時可以再次處理。如果只顧著整理結構,很容易順便破壞取消、Lease、lost ACK 或冪等語意。
因此兩種結構都必須保留:
重試時間與內部責任可以調整;HTTP Contract、取消傳遞、Lease、lost ACK、冪等鍵與副作用次數則必須維持原本語意。
回到標題,兩份程式碼都通過測試,只能證明它們目前具有相同行為價值。加入事前保密的重試規則後,才看見結構價值的差異:整合式候選用較短修改把矩陣留在 Dispatcher;Policy 分離式則讓三次 Agent 都找到同一個純決策落點,穩定的 EF 與 Gateway 編排不必承擔規則內容。
我最後接受 Policy Run 03,因為重試矩陣已經是真實需求,也重用了既有路由判斷。這份選擇不是由 Token、行數或設計模式名稱決定;如果矩陣沒有出現,整合式版本仍會是更精簡的答案。
〈軟體的兩種價值〉讓我把「現在能運作」和「下一次仍容易修改」分開驗收。CLEAN 則把它轉成 User 的操作:提供 Repository 真實情境、固定不能漂移的行為,再要求 Agent 回報新規則落點、穩定責任是否被迫修改,以及為什麼應該在這個抽象層次停下來。
明天會接著談〈獨立性〉。Policy 與 Dispatcher 分開,只回答原始碼中的一小部分邊界;Use Case、執行方式、團隊開發與部署能不能各自改變,還要分開檢查。