iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護系列 第 22

Day 22|通知 Provider 已有介面,AI 何時才需要加上 Namespace 防線或拆成 Assembly?

  • 分享至 

  • xImage
  •  

安安~我是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。我會再問五個問題:

  1. 現在真正要隔離哪一種變化?
  2. 哪一種機制才能攔住這項風險?
  3. 現在升級會增加哪些 Contract、Mapping、Project 與維運成本?
  4. 等需求出現再升級,遷移成本是否仍在可控制範圍內?
  5. 這項決策是否難以撤回,或涉及安全、故障隔離與外部供應商風險?

這就是 Option Value:今天不必把所有未來做成 Production Code,但要保留可升級的接縫與重新評估條件。現有 Port 若能讓未來遷移保持局部,延後物理拆分就是一個有意識的選擇,不是拖延。

Clean Code 所說的外掛式依賴方向,不等於 .NET 的執行期 Plugin

把〈架構邊界〉落到今天的 .NET 案例時,我先處理一件事:核心政策擁有自己需要的契約,外部細節反過來依賴並實作它。UI、資料庫、Framework 與第三方服務都可以留在這條依賴方向的外側。

這裡的外掛式設計描述的是依賴方向;.NET Runtime Plugin 還包含執行期發現、載入外部 Assembly,以及處理各 Plugin 相依套件的機制,常見做法會用到 AssemblyLoadContextAssemblyDependencyResolver

今天沒有掃描 Plugin 資料夾、沒有動態載入,也沒有熱插拔。Assembly 候選只是新增 Class Library 與單向 Project Reference,因此後文統一稱為「Assembly 邊界」。它取得的是編譯期隔離,不是 Runtime Plugin、獨立程序或獨立部署。

三種方案的約束力依序提高:現有 Port 主要依靠介面、Composition Root 與 Review;Namespace 方案再加入 CI Source Scan;Assembly 則把部分規則交給 Compiler。只有未來真的需要獨立資源、故障隔離、版本或發布時,才繼續評估 Process 與 Deployment 邊界。

實驗起點:Port 已隔離 Provider 選擇,但跨界資料仍綁著 EF Entity

今天從昨天的接受版本開始:

  • Commit:7f7b245d8493472eb6a2df833a385ed1ddda8625
  • Annotated Tag: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。

同一條通知流程,分別只靠 Port、加上 CI Source Scan,再交給 Compiler 限制

我使用相同的 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 成本超過目前需求

情境一:保留現有 Port,允許零 Production Diff

第一組要先確認四件事:

  1. Delivery Policy 是否只看通知能力,不認識 Demo/Queued。
  2. 具體 Provider 是否只在 Composition Root 被選擇。
  3. 加入第三個 Provider 時,是否已有明確擴充位置。
  4. 目前是否存在需要 Package、Process 或獨立部署才能解決的問題。

四項都成立時,Agent 必須停在零 Production Diff,不能為了展示成果再加 Factory、Wrapper 或第二層 Interface。

情境二:維持單一 Project,加入 Namespace 與依賴檢查

第二組仍只能使用原本的 WorkItems.Api.csproj。它要把 Provider、設定與 Composition Root 的位置整理清楚,再新增 Dependency Test,阻止 Use Case 直接引用具體 Provider Namespace。

UseCases.NotificationDelivery
        │
        └── 可以依賴 Consumer Port

NotificationProviders.Demo/Queued
        │
        └── 不得被 Use Case 直接引用

這組不會多出 DLL 或部署單位。它要回答的是:不拆 Project,能不能先讓已知的錯誤引用在 CI 自動失敗?

情境三:建立通知 Contract 與 Provider Assembly

第三組最多可以新增兩個 Production Class Library:

  1. 穩定的通知 Contract Assembly。
  2. Demo/Queued Provider Assembly。

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 後,才進入架構比較。

現有 Port 已能替換 Provider,三次實驗都停在零 Production Diff

三個 Agent 分別追蹤 Delivery Policy、Dispatcher、Provider 設定與 DI 註冊,最後都沒有修改任何檔案。

它們得到相同結論:

  • NotificationDispatchPolicy 不依賴具體 Provider。
  • NotificationOutboxDispatcher 只依賴 IWorkItemNotificationSender
  • Demo/Queued 只在 Composition Root 被選擇。
  • 第三個 Provider 可以實作現有 Port,再加入設定驗證與 DI Mapping。
  • 現在沒有獨立套件、版本、團隊發布或部署需求。

在同一團隊、同一版本與同一個 API Host 裡,現有 Port 已足以替換 Provider,因此零 Production Diff 是合理結果。不過它仍有兩個升級訊號:Use Case 直接引用具體 Provider 時,只能靠 Review 發現;介面直接接收 WorkItem EF Entity,也不適合作為獨立套件的公開契約。

Namespace 候選:CI 能攔截明列引用,Compiler 仍允許跨 Namespace 依賴

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 候選:Compiler 禁止反向引用,也新增跨界 Contract 與 Mapping

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。依賴測試則檢查:

  • Contracts 不得反向引用 API 或 Providers。
  • Providers 只能依賴 Contracts,不得引用 API。
  • 跨界資料不能包含 EF、HTTP、DbContext 或 Host 型別。

這份候選取得更強的編譯期限制,同時增加兩個 Class Library、公開 Contract、Mapping、Project Reference、Lock File 與測試。

這仍是靜態 Project Reference。Provider DLL 會和 WorkItems.Api.exe 一起發布,使用相同版本、發布與版本復原流程;它沒有 Runtime Plugin、熱插拔或獨立部署能力。

Port 成本最低、Namespace 多一層 CI 防線、Assembly 提供編譯期隔離

方案 行為結果 新增保護 結構成本 仍未取得
現有 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;完整中位數保留在公開實驗紀錄中。

壓力測試:今天不拆 Assembly,未來會不會付出更高遷移成本?

保留現有 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 與跨團隊交付仍要另外驗證。

從既有 Port 到 Namespace 架構測試、Assembly 與 Process 的邊界升級決策樹

圖:邊界應停在目前足以保護需求的最小層級,只有錯誤引用、編譯隔離或獨立部署壓力出現時才升級。

現階段保留現有 Port,其他邊界都有明確啟用條件

壓力測試完成後,我才決定本系列接續哪一份版本:

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

固定判斷規則留在 AGENTS.md,會隨情境改變的選擇寫進 ADR

跨任務都應重複執行的判斷流程,適合整理成 Repository Instruction;只在目前情境成立的架構選擇,則適合用 ADR 保存原因與重新評估條件。

今天有兩種資訊需要分開:

  • AGENTS.md 可以要求 Agent 每次先檢查 Ownership、版本、部署、既有接縫與停止條件。
  • 「目前不拆 Assembly」只適用於現在的 Repository,應寫進 ADR,連同觸發條件保存。

如果把暫時決策寫成「永遠禁止拆 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 現況是否已碰到觸發條件。

CLEAN 把架構時機寫成 Agent 可以執行的條件

C — Context-Aware Code 情境感知:先提供真實的團隊、版本與部署狀態

同樣是通知 Provider,小型團隊與單一部署可能只需要 Consumer Port;跨團隊套件會需要 Assembly/Package;獨立故障與擴充才可能需要 Process 或 Deployment。

C — Context-Aware Code 情境感知 要求 User 提供 Repository 已存在的 Port、Provider、Ownership、版本、部署方式與過往錯誤紀錄。Agent 取得這些情境後,才有能力判斷哪一層保護現在值得支付。

E — Explicit Intent and Boundaries 意圖明確:指定可做範圍、禁止事項與停止點

「把通知模組改乾淨」沒有可驗收邊界。更適合交給 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。

這次零 Diff 是停手,不是漏做

回到標題,既有介面已經隔離目前的 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。

參考資料


上一篇
Day 21|拆成多個 Project,業務流程、執行方式、團隊與部署就真的獨立了嗎?
下一篇
Day 23|Controller、EF Core 與第三方 SDK 為什麼不該決定 Use Case?用六角形架構隔離外部細節
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言