iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

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

Day 23|Controller、EF Core 與第三方 SDK 為什麼不該決定 Use Case?用六角形架構隔離外部細節

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天整理元件邊界時,我保留了既有通知 Port,卻也發現一個不能忽略的問題:它接收的參數仍是 EF Core 使用的 WorkItem Entity。表面上隔離了通知 Provider,核心實際上還在說 Persistence 的語言。

再往回追,WorkItemsController 與背景 Worker 雖然已共用 IOverdueWorkItemProcessor,Processor 後方仍直接連著具體 Marker、EF Core 實作與通知 Provider。入口看起來有介面,不代表整條 Use Case 都已經擺脫外部型別。

這次實驗最後留下兩條有真實用途的邊界:通知 Port 改用 Use Case 自己擁有的 WorkItemNotification,由 SDK Adapter 翻譯外部狀態與例外;Processor 則改依賴 Persistence Port,EF Core 實作移到外層。Controller、DbContext 與 SDK 仍然存在,只是不再決定核心契約的形狀。

不過,新增的 Source Boundary Test 也暴露一個盲點:它只掃描 UseCases 目錄,位於 API 根目錄的 OverdueWorkItemProcessor 沒有被檢查。這個缺口會成為明天的主題。

所以今天不只是畫一張六角形架構圖,而是沿著這條執行路徑確認外部技術究竟在哪裡被翻譯、哪些型別應該停在 Use Case 外圍:

HTTP/Worker → Use Case → Persistence → 外部通知 Provider

為了避免架構名詞蓋過實際問題,今天的四個階段都沿著這條路徑前進:

實驗階段 為什麼要做 這次要留下的邊界
統一輸入 Controller 與 Worker 若各自實作規則,核心會先分裂成兩份 兩種入口都只呼叫同一個 Input Port
探索 SDK 不先確認外部狀態與例外,就無法定義正確翻譯 Learning Test 固定 SDK 的成功、重複、拒絕、例外與取消
隔離通知 EF Entity 或 SDK Request 一旦進入 Port,外部型別就會決定核心契約 Use Case 擁有通知資料,Adapter 負責雙向翻譯
隔離 Persistence Processor 直接依賴具體 Marker 或 DbContext,仍無法脫離 EF Core Processor 只依賴 Persistence Port,EF Core 實作留在外層

這張表也是本文的閱讀地圖:先找到 Repository 的真實耦合,再用六角形架構替每個角色命名,最後才判斷哪些 Port 與 Adapter 值得保留。

〈整潔的邊界〉先問:Controller、EF Core 與 SDK 各自該停在哪裡?

《無瑕的程式碼 第二版》的〈整潔的邊界〉提醒我們,第三方套件、UI、資料庫與外部系統都可能改變。問題不在於核心永遠不能呼叫它們,而是這些外部型別、生命週期與錯誤語意,不能反過來定義 Use Case。

我把今天會用到的觀念整理成五個部分:

觀念 它想隔離什麼 放到 Work Item API 會看到什麼
第三方 Framework 套件型別、生命週期與錯誤語意 ASP.NET Core、EF Core、通知 SDK
UI 與 Application 邊界 輸入輸出方式不應決定業務流程 Controller 與 Worker 都只是 Input Adapter
六角形架構 核心透過 Port 與 Adapter 和外界溝通 IOverdueWorkItemProcessor、Persistence Port、通知 Port
探索邊界 先在安全範圍理解未知套件 Learning Test 直接操作 SDK 公開 API,確認實際行為
使用尚不存在的程式碼 先定義自己真正需要的能力 Use Case 先定義通知資料與介面,再接 SDK Adapter

Controller 與 Worker 負責把外部輸入轉成 Use Case 呼叫;Persistence Port 隔離資料庫能力;Learning Test 確認 SDK 真正會回什麼;通知 Adapter 再將外部狀態翻譯成核心能理解的結果。

共同判準只有一個:跨越邊界時,Contract 應由 Use Case 的目的決定。HTTP Model、EF Entity、SDK Response 與 Provider Exception 不應直接成為核心語言。

六角形架構如何讓 HTTP、資料庫與 SDK 接上同一個應用程式核心?

六角形架構又稱 Ports and Adapters Architecture,由 Alistair Cockburn 在 2005 年發表。它把設計焦點從「上層與下層」改成「應用程式內部與外部」:即使拿掉特定 UI 或資料庫,其他輸入方式與替代實作仍能透過相同契約驅動核心能力。

下面這張圖是我依原始概念重新繪製的版本:

六角形架構原始概念:Driving 與 Driven Actors 透過 Port 和 Adapter 連接應用程式核心

圖:左側由 HTTP、Worker 與 Tests 驅動核心;右側則是核心主動使用的 Database、Notification SDK 與 Message Queue。

六邊形只是視覺隱喻,Port 數量取決於系統與外界的互動

實作時不需要刻意建立六個 Interface、六個 Adapter 或六個 Project。Cockburn 在原始文章中提到,Port 的數量取決於應用程式與外界進行哪些「有目的的對話」。真正要確認的是,每個 Port 是否代表一項穩定而明確的互動目的。

例如「處理逾期工作項目」是一種應用程式能力,可以由 HTTP、背景 Worker 或自動化測試觸發。這三項技術不需要各自擁有一套 Use Case,而是透過不同 Input Adapter 接上同一個 Input Port。

Primary 與 Secondary 看誰發起互動,不看它畫在左邊還是右邊

使用者、HTTP Request、Worker 或自動化測試會主動觸發 Use Case,因此屬於 Driving/Primary;資料庫、通知 Provider 與訊息佇列則是核心執行途中主動呼叫的 Driven/Secondary。圖上的左右位置只是方便閱讀。

角色 誰發起互動 在 Work Item API 的例子
Driving/Primary Actor 外界主動要求應用程式執行一項能力 HTTP Client、排程系統、自動化測試
Input Adapter 把外部輸入翻成應用程式能理解的呼叫 WorkItemsControllerOverdueProcessingWorker
Input Port 宣告應用程式允許外界呼叫的能力 IOverdueWorkItemProcessor
Application/Domain Core 執行 Use Case 與業務政策 OverdueWorkItemProcessor
Output Port 宣告核心完成工作時需要的外部能力 Persistence Port、IWorkItemNotificationSender
Output Adapter 把核心語言翻成特定技術操作 EF Core Adapter、Notification SDK Adapter
Driven/Secondary Actor 由應用程式主動查詢、寫入或通知 SQLite、通知 Provider、Message Queue

自動化測試通常畫在 Primary 一側,因為它負責驅動應用程式;Mock Database 則在 Secondary 一側,因為它替代的是核心原本會呼叫的資料庫。

Port 是對話目的,Adapter 才是技術翻譯

這裡的 Port 是應用程式對外提供或需要的一種對話契約,和網路 TCP Port 不同。Contract 由核心擁有,內容取決於 Use Case 想完成什麼。

Adapter 處理技術差異。HTTP Adapter 負責 Route、JSON 與 Status Code,EF Core Adapter 處理 DbContext、查詢與 Transaction;第三方 SDK Adapter 則翻譯 Request、Response、Exception 與 Cancellation。

同一個 Port 可以接上不同 Adapter,只要它們都守住相同的對話契約。也正因如此,Port 不該直接暴露 EF Entity 或 SDK Request,否則核心仍然得認識外側技術。

圖中的藍色箭頭表示執行時呼叫方向:外界透過 Input Adapter 啟動核心,核心需要外部能力時再呼叫 Output Port。橘色虛線則代表原始碼依賴,外側 Adapter 依賴核心定義的 Port。兩種箭頭不能混在一起判讀。

把 Work Item API 放回這張圖後,實際角色如下:

flowchart LR
    subgraph Inputs["Input Adapters"]
        HTTP["WorkItemsController"]
        Worker["OverdueProcessingWorker"]
    end

    InputPort["IOverdueWorkItemProcessor<br/>Input Port"]
    UseCase["OverdueWorkItemProcessor<br/>Use Case"]
    PersistencePort["IOverdueWorkItemMarker<br/>INotificationOutboxDispatcher"]
    NotificationPort["IWorkItemNotificationSender"]

    subgraph Outputs["Output Adapters"]
        EF["EF Core Persistence Adapter"]
        SdkAdapter["Notification SDK Adapter"]
    end

    SQLite["SQLite"]
    SDK["External Notification SDK"]

    HTTP --> InputPort
    Worker --> InputPort
    InputPort --> UseCase
    UseCase --> PersistencePort
    UseCase --> NotificationPort
    PersistencePort --> EF
    NotificationPort --> SdkAdapter
    EF --> SQLite
    SdkAdapter --> SDK

這張 Mermaid 圖畫的是執行時協作路徑:Use Case 先呼叫自己定義的 Port,再由 Composition Root 選好的 Adapter 接手,最後走到 SQLite 或通知 SDK。原始碼依賴則反向由 Adapter 指向核心 Contract。

先分清 Port、Adapter 與 Wrapper,避免只替外部呼叫多包一層

這幾個詞在專案裡很容易混用。我不追求唯一命名,但要先說清楚它們負責什麼。

名稱 今天採用的意思 只有什麼情況才值得增加
Port 核心擁有的能力契約 核心真的需要呼叫或被呼叫
Adapter 翻譯兩邊的資料、錯誤與行為語意 外部契約與核心語言不同
Gateway 對外部能力提供一個應用程式入口 想把外部互動集中管理時
Wrapper 包住既有 API 或物件 需要隔離建立方式、生命週期或測試接縫時

Wrapper 若只是把 sdk.Send() 改叫 wrapper.Send(),參數、回傳值、Exception 與使用方式仍原封不動,核心只是多了一次跳轉。

Adapter 必須做真正翻譯。例如 SDK 回傳 AcceptedDuplicateRequestRejected,Use Case 需要知道的可能只有「這次通知是否已被 Provider 接受」。兩邊語意不同,Adapter 才有存在價值。

專案原本的 DemoNotificationGatewayQueuedNotificationGateway 都實作 Use Case 擁有的通知 Port,名稱可以繼續保留。判斷它是不是 Adapter,仍要看它是否把外部 Request、狀態與錯誤語意翻譯成核心需要的 Contract。

實驗先固定三個 Repository 缺口,避免 Agent 憑架構口號自行加層

實驗沿用昨天的系列版本:

  • Commit:7f7b245d8493472eb6a2df833a385ed1ddda8625
  • Annotated Tag:day-22-architecture-boundary

讀者可以直接切到相同起點:

git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git fetch --tags
git switch --detach day-22-architecture-boundary

如果 Prompt 只要求「新增第三方 SDK」,Agent 很可能只處理套件註冊與呼叫,既有 EF Core 與通知邊界仍不會改變。因此,我先沿 Controller、Worker、Processor、EF Core 實作與通知流程檢查 Repository。

目前有三個可驗證缺口:

  1. OverdueWorkItemProcessor 仍依賴具體的 OverdueWorkItemMarker
  2. UseCases 目錄內仍放著直接使用 EF Core 與 WorkItemsDbContext 的實作。
  3. IWorkItemNotificationSender 直接接收 EF Entity WorkItem

這三項缺口分別讓 Persistence 實作進入高階協調器、讓 Framework 影響範圍擴大,以及讓 Persistence Model 成為跨界 Contract。它們也是四階段實驗的設計起點。

本次驗證的是 Agent 能否依真實 Repository Context 建立可執行邊界;沒有測試 Agent 在未取得缺口資訊時能否自行完成整套架構診斷。

起點可以簡化成:

public sealed class OverdueWorkItemProcessor(
    OverdueWorkItemMarker overdueWorkItemMarker,
    INotificationOutboxDispatcher notificationOutboxDispatcher)
{
    // ...
}

public interface IWorkItemNotificationSender
{
    Task<bool> SendOverdueAsync(
        WorkItem workItem,
        string idempotencyKey,
        CancellationToken cancellationToken);
}

這裡傳遞的 WorkItem 是 EF Core Persistence Model,不是 Use Case 自己定義的通知資料。即使替 SDK 多包一層,Entity 仍然越過邊界。

四階段實驗沿同一條執行路徑,逐步把外部型別留在核心之外

這次只執行一個 Session,依序完成 Input Adapter、SDK Learning Test、通知 Port/Adapter 與 Persistence 隔離。實驗要觀察的是 Agent 能不能在同一條執行路徑上建立連續邊界,並保留既有行為。

因為沒有其他架構候選作為控制組,結果不能用來宣稱六角形架構一定最好或一定比較省 Token。模型固定使用 Codex GPT-5.6-SOL-HIGH。下面保留主要 Prompt;逐字紀錄與驗證指令則收在公開實驗資料。

你正在 AI-CleanCode-API-Demo 的隔離 Worktree 中執行正式實驗。

固定實驗條件:
- 起始 Commit 必須是 7f7b245d8493472eb6a2df833a385ed1ddda8625。
- 模型固定為 gpt-5.6-sol,Reasoning effort 固定為 high。
- 先閱讀 AGENTS.md、Solution、Production Code 與既有測試,再修改。
- 不得修改 HTTP Route、Method、Status Code、Request/Response JSON、SQLite Schema、既有通知與儲存語意。
- 不得使用外部網路、帳號或真實通知服務。

Repository 上下文:
- WorkItemsController 透過 HTTP 呼叫 IOverdueWorkItemProcessor。
- OverdueProcessingWorker 透過排程呼叫相同 Processor。
- 不要再建立第二套 Use Case、Controller、Worker 或 Host。
- 目前仍有三個待驗證缺口:Processor 依賴具體 Marker、UseCases 仍包含 EF Core/DbContext 實作、通知 Port 直接接收 EF Entity。

請依序完成四個階段:

第一階段:確認現有 Input Boundary
- 保留 Controller 與 Worker 共用 IOverdueWorkItemProcessor。
- 用測試證明 HTTP 與 Worker 都透過同一個 Use Case Port 執行。
- 不新增 CLI、第二個 API 或新 Host。

第二階段:建立可重播的第三方 SDK Learning Test
- 新增本機 Class Library,作為可控制、可重播的模擬第三方通知 SDK。
- SDK 要有自己的 Request、Response、狀態碼與 Provider Exception,能表達接受、重複請求、拒絕、Provider 失效與取消。
- SDK 不得引用 WorkItems.Api。
- Learning Test 只能透過 SDK 公開 API 驗證行為。
- Production Use Case 不得引用 SDK 型別。

第三階段:由 Use Case 定義 Port,再由 Adapter 翻譯
- 由應用程式需求定義通知輸入模型與 IWorkItemNotificationSender,不得直接傳遞 EF Entity。
- 新增 SDK Adapter,只翻譯目前需要的逾期與升級通知。
- Adapter 必須翻譯 Request、Response、拒絕、Provider Exception 與取消語意,不得一對一複製整套 SDK。
- 保留 Demo/Queued Provider,並能透過 Composition Root 選擇模擬 SDK Adapter。

第四階段:隔離 Persistence 實作並驗證替換能力
- OverdueWorkItemProcessor 只依賴自身需要的 Port。
- EF Core/WorkItemsDbContext 實作留在外層 Adapter 區域。
- 用測試用 Adapter 證明 Processor 不需要修改;不要宣稱這等於完成真實資料庫遷移。
- 新增可重跑 Boundary Test,阻止 UseCases 引用 MVC、EF Core、DbContext、EF Entity 或模擬 SDK 型別。

必須保留:
- POST /api/work-items/process-overdue 的 Contract。
- Controller 與 Worker 呼叫同一個 Processor。
- 狀態與通知意圖先以同一個 Save 原子儲存,再 Dispatch。
- 通知失敗保留逾期狀態與 Pending Outbox。
- Provider Exception 轉成通知失敗,取消繼續向上傳遞。
- 重複執行、lost ACK、並行 Claim、Retry Boundary 與 Idempotency Key 語意。

停止條件:
- 不新增微服務、第二個部署 Artifact、動態 Plugin Loader 或網路呼叫。
- 不為穩定的 ASP.NET Core/EF Core 每個 API 再包一層。
- 不修改 Create、Assign、Complete 流程。
- 若必須變更外部 Contract 或 SQLite Schema,停止並回報。

完成後執行 Locked Restore、Release Build、完整測試、Format、套件弱點掃描與系列 Smoke,並回報上下文、Learning Test、Port/Adapter、替換驗證、錯誤語意、Diff 與停止理由。

Prompt 提供既有 Class、三個已知缺口、必須保留的行為與停止條件;新 Adapter、跨界資料與 Persistence 實作的名稱則留給 Agent 決定。

第一階段:Controller 與 Worker 都只是 Input Adapter

第一階段先確認 HTTP 與背景排程是否真的驅動同一個 Use Case。若兩個入口各自保有一套逾期流程,後面即使隔離 EF Core 與 SDK,業務規則仍然可能分裂。

Agent 確認兩個入口:

  • HTTP 請求由 WorkItemsController 接收。
  • 排程由 OverdueProcessingWorker 觸發。

兩邊最後都呼叫:

public interface IOverdueWorkItemProcessor
{
    Task<ProcessOverdueResponse> ProcessAsync(
        CancellationToken cancellationToken);
}

Controller 處理 HTTP Contract,Worker 處理排程與停止訊號,逾期規則統一交給 Processor。Input Boundary Test 顯示兩個入口都只透過 IOverdueWorkItemProcessor 進入核心,也保留 Cancellation Token。

第一階段刻意不新增 CLI 或第二個 Host。輸入方式可以不同,Use Case 不需要複製兩份。

第二階段:先用 Learning Test 理解未知 SDK

這次模擬 SDK 同時提供成功、重複請求、拒絕、例外與取消五種結果。如果沒有先驗證這些行為,Prompt 無法告訴 Agent DuplicateRequest 應算成功還是失敗,也無法確認 Cancellation 會不會被一般 Exception 處理吃掉。

Learning Test 是範圍很小的探索測試,用來確認第三方套件實際呈現的資料、狀態與例外。它測的是「我們對外部 API 的理解是否正確」,不負責替套件作者驗證 SDK 本身,也不應滲進正式 Use Case。

為了讓公開實驗能重播,我沒有連接真實商業服務,而是新增本機模擬 SDK:

WorkItems.SimulatedExternalNotificationSdk

它沒有引用 WorkItems.Api,也沒有網路呼叫。Learning Tests 只透過公開 API 確認五種行為:

  1. 第一次送出回傳 Accepted
  2. 相同 Idempotency Key 回傳 DuplicateRequest,並沿用原本的 Provider Request ID。
  3. Provider 可以明確回傳 Rejected
  4. Provider 失效時拋出自己的 Exception。
  5. Cancellation Token 被取消時,OperationCanceledException 繼續向上傳遞。

Learning Test 先固定外部系統會回答什麼,Adapter 的翻譯規則再從這五個結果出發。這個本機模擬只能支援公開實驗重播,不能取代真實 Provider 的官方文件、Sandbox 與整合測試。

第三階段:通知契約由 Use Case 擁有,SDK Adapter 負責翻譯

如果 Port 直接接收 SDK Request 或 EF Entity,外部型別就會成為 Use Case Contract;下一次替換 Provider 或 Persistence 時,核心也會被迫一起修改。

因此,Agent 先把通知 Port 的輸入改成 Use Case 自己擁有的資料:

public sealed record WorkItemNotification(
    Guid Id,
    string Title,
    string Priority,
    DateTimeOffset DueAtUtc,
    string? Assignee);

public interface IWorkItemNotificationSender
{
    Task<bool> SendOverdueAsync(
        WorkItemNotification workItem,
        string idempotencyKey,
        CancellationToken cancellationToken);

    Task<bool> SendEscalationAsync(
        WorkItemNotification workItem,
        string idempotencyKey,
        CancellationToken cancellationToken);
}

這份 Port 只描述逾期 Use Case 目前需要的兩種通知,輸入也是核心擁有的 WorkItemNotification。SDK 其餘方法與 Request 型別都留在 Adapter 外側。

接著由 SimulatedExternalNotificationSender 將這份資料翻成 SDK Request,再把 SDK 結果翻回 Use Case 能理解的成功或失敗:

SDK 結果 Adapter 回傳 原因
Accepted 成功 Provider 已接受通知
DuplicateRequest 成功 相同 Idempotency Key 已被接受,不應再製造一次失敗
Rejected 失敗 交給既有 Outbox Retry 規則處理
Provider Exception 失敗 保留 Pending Outbox,後續重試
Cancellation 繼續拋出 取消不是一般通知失敗,不能被吞掉

這個 Adapter 確實翻譯了資料、回傳狀態、Exception 與 Cancellation,已經不是替 SDK 換一個方法名稱。Composition Root 保留 Demo/Queued Provider,只新增明確的 SimulatedExternalSdk 設定值;Use Case 不需要知道目前選到哪一個實作。

第四階段:用測試版 Persistence Adapter 驗證 Processor 不必直接認識 DbContext

處理完通知後,起點的 Processor 仍依賴具體 Marker,Marker 與 Dispatcher 也還在 UseCases 裡使用 WorkItemsDbContext

接受版本把 Processor 改成:

public sealed class OverdueWorkItemProcessor(
    IOverdueWorkItemMarker overdueWorkItemMarker,
    INotificationOutboxDispatcher notificationOutboxDispatcher)
    : IOverdueWorkItemProcessor
{
    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);
    }
}

具體的 EfCoreOverdueWorkItemMarkerEfCoreNotificationOutboxDispatcher 移到外層 Persistence 區域。EF Core、SQLite 查詢、Lease 與 SaveChangesAsync 仍然存在,只是不再由 Use Case 自己擁有。

測試使用兩個簡單 Adapter 取代 EF Core 實作,直接執行同一個 Processor。結果顯示協調流程已能透過 Port 完成,不需要直接取得 DbContext

這只驗證接縫成立。若真的換成 PostgreSQL、MongoDB 或遠端服務,仍要另外處理查詢能力、Transaction、一致性、效能與故障模式。

邊界測試確認 UseCases 目錄沒有外部引用,卻漏掉根目錄的 Processor

檔案今天移對位置,不代表邊界會一直維持。下一次 Agent 修改功能時,仍可能把 DbContext 加回 UseCases

因此本次新增 ProductionBoundaryTests,掃描 src/WorkItems.Api/UseCases 的 Production Source,禁止出現:

Microsoft.AspNetCore
Microsoft.EntityFrameworkCore
WorkItemsDbContext
WorkItems.Api.Models/EF Entity WorkItem
WorkItems.SimulatedExternalNotificationSdk

這比只在 Prompt 寫「請遵守六角形架構」更明確,因為禁令能重複執行。不過,結果只證明指定目錄內沒有命中這些引用,不能證明所有 Use Case 都受到保護。

OverdueWorkItemProcessor 位於 API Project 根目錄,不在目前掃描範圍。如果未來有人把 DbContext 注入這個 Processor,測試仍可能維持綠燈。

Boundary Test 除了列出禁止依賴,也必須確認自己真的找得到所有應受保護的 Production Type。缺少 Discovery 驗證,測試很容易只提供一種看似安心的綠燈,設計 Review 也不能省略。

重構邊界後重新驗證 API 行為,確認架構調整沒有改變系統答案

Agent 完成後,我沒有只看它最後一句「全部通過」。主流程從相同 Worktree 重新檢查三類結果:

驗證類型 結果
建置、格式與套件檢查 通過,沒有新增編譯、格式或已知套件弱點問題
行為與邊界測試 Input Adapter、SDK 行為、Adapter 翻譯、Persistence 接縫與指定目錄的 Source Boundary Test 皆通過
系列 Smoke 第一次執行會處理逾期項目並送出通知;第二次執行不再處理,也沒有重複通知

這些結果支持既有行為沒有漂移,不能補足 Source Boundary Test 未掃到的 Production Type。

這也不是一次小型重構:Diff 同時加入模擬 SDK、Learning Test、Boundary Test,並搬移兩個 EF Core 實作。檔案數與行數不是整潔架構分數;我接受它,是因為每項修改都對應四個實驗階段,而且沒有順便新增第二個 Host、微服務、動態 Plugin Loader 或資料庫遷移。

只有跨界語意與變更壓力已經出現時,才新增 Port 或 Adapter

我採用通知語意 Adapter,是因為 SDK 的資料、狀態、Exception 與 Cancellation 確實不同;保留 Persistence Port,則是因為 Processor 原本直接依賴 EF Core 實作。這兩條接縫都有目前程式碼與測試可以指出的問題:

  1. 通知 SDK 與 Use Case 的資料、回傳狀態、Exception 和取消語意不同,需要語意 Adapter。
  2. UseCases 確實直接依賴 EF Core/Entity,Processor 也依賴具體 Marker,需要 Persistence Port。

其他做法仍有適用情境:

情境 比較適合的做法 對後續 Agent 的影響
Framework 只出現在 Composition Root 或外層 Adapter,契約穩定且沒有替換壓力 直接使用,不額外包裝 少一層檔案與跳轉,而且外部型別不會進入核心
只需要隔離物件建立、生命週期或難以測試的靜態 API 薄 Wrapper Agent 只需替換技術接縫,不必誤以為兩側語意不同
兩邊資料、錯誤、取消、重試或冪等語意不同 語意 Adapter 翻譯集中一處,換 SDK 時不必追進 Use Case 修正外部語意
Use Case 需要某項外部能力,但不該依賴具體技術 由 Use Case 擁有 Port,外層 Adapter 實作 核心先說明真正需要的能力,不直接拿 EF Entity 或 SDK Request 當契約
Provider 有獨立團隊、套件或版本契約 再升級 Assembly/Package 邊界 Project Reference 能辨認編譯期邊界,也要處理更多契約與版本資訊
需要獨立啟停、故障或部署隔離 評估 Process/Deployment 邊界 任務範圍能依服務切開,同時增加跨程序失敗與維運 Context

ASP.NET Core Controller 與 Adapter 內部的 DbContext 繼續直接使用,SDK Adapter 也必然引用第三方 SDK。整潔邊界限制的是這些技術能影響核心的範圍,不要求把每個穩定 API 都包裝一次。

外部型別會成為下一個 Agent 參照的 Repository 範例

AI Agent 會從既有程式碼推斷專案慣例。若 Use Case 已接收 EF Entity 或第三方 SDK Request,下一次新增功能時,它很可能沿用同一條捷徑,讓外部技術繼續往核心擴散。等 Provider 或資料庫更換時,Agent 就得讀更多技術型別、Mapping 與例外規則,才能還原業務意圖。

這是 Agent 模仿 Repository 既有寫法時的設計風險,本次沒有把它量化成下一個 Session 的固定結果。

邊界也不是越多越好。每新增一個 Port、Adapter、DTO 或 Project,Agent 都多一段需要搜尋與驗證的路徑。適合 AI Coding 的做法,是讓外部型別停在技術邊緣;只有出現語意翻譯、替換需求或獨立變更壓力時,才增加下一層邊界。

這次的決策可以濃縮成四步:外層技術穩定就直接使用;建立方式難測試時加 Wrapper;跨界語意不同時加 Adapter;核心需要穩定能力時才由 Use Case 定義 Port。團隊、版本、故障或部署需求真的分離後,再考慮 Assembly 或 Process 邊界。

這次量到的是建立邊界的成本,是否降低後續 Token 要等下一次變更

Agent 需要理解 Repository、建立 Learning Test、調整 Port、搬移 Persistence 實作並重新驗證,因此這次成本不低。單一 Session 沒有同品質控制組,不能換算成 Token 節省。目前可以確認的是:

  • Agent 不用修改 Processor,就能用測試 Adapter 替換 Persistence 技術。
  • 未來通知 Provider 不必接收 EF Entity。
  • SDK 狀態與 Exception 集中在一個 Adapter 翻譯。
  • 指定目錄的 Boundary Test 能自動攔截部分錯誤依賴。

真正值得觀察的是,下一次替換 Provider 或 Persistence 時,這些接縫能否縮小 Agent 需要讀取的範圍、Diff 與工具呼叫。邊界太少,外部語言會擴散進核心;邊界太多,Agent 又得跨更多檔案重建流程。架構可能降低後續成本,建立架構本身同樣需要付出成本。

UseCases 禁止引用 EF/SDK 的規則交給測試,是否拆 Project 則依 Repository 情境判斷

能明確判定對錯的依賴禁令,適合交給 Boundary Test 重複執行;Project 數量、Host 邊界、Provider 是否跨團隊等設計決策,則必須根據 Repository 規模與變更壓力判斷,保留在 Prompt、ADR 與人工決策裡。

若要讓後續 Agent 重複使用,可以先用 AGENTS.md 格式整理這段最小 Policy:

## Clean Boundary Policy

- 修改跨邊界流程前,先列出現有 Input Adapter、Use Case、Persistence 與外部 Provider,不得跳過 Repository 現況直接套用架構範本。
- Port 與跨界資料由使用它們的 Use Case 擁有,不得直接暴露 HTTP Model、EF Entity、DbContext 或第三方 SDK 型別。
- Adapter 必須說明它翻譯的資料、錯誤、取消、重試或冪等語意;沒有翻譯責任時,不新增一對一 Wrapper。
- ASP.NET Core、EF Core 與 SDK 可以留在外層 Adapter/Composition Root,不要求包覆每一個穩定 API。
- 新增 Project、Process 或部署邊界前,先提出獨立團隊、版本、故障、資源或發布需求;沒有證據時維持目前接縫。
- Boundary Test 必須回報實際掃描的 Production Project、目錄與 Type;不得只回報禁止清單與測試全綠。
- 宣稱 Boundary Test 有效前,至少放入一個刻意違規的 Production 依賴,確認測試真的會轉紅,再移除違規程式碼。
- 完成後回報外部技術引用掃描、Boundary Test、Contract/Schema 變更與停止理由。

目前這仍是文章中的 Policy 範例,尚未寫入 API Demo。日後可重用的判斷流程可以再抽成 Skill;Repository 專屬的掃描範圍與架構事實仍要留在專案內。若 Agent 不知道哪些 Type 才是真正 Use Case,再完整的禁止清單也可能掃錯地方。

CLEAN 原則

C — Context-Aware Code 情境感知:先提供足以判斷邊界的專案事實

C — Context-Aware Code 情境感知 要求 User 在 Agent 動手前,先確認 Controller、Worker、Processor、Outbox、Persistence 與 Provider 的真實協作方式。

這次我把三個已知缺口、通知失敗語意、既有接縫與禁止擴張的範圍交給 Agent。它依這些事實完成邊界,而不是自行猜測專案應該套用哪一種架構。

E — Explicit Intent and Boundaries 意圖明確:跨界語言由 Use Case 先說清楚

E — Explicit Intent and Boundaries 意圖明確 透過 Prompt 說清楚既有 Port、三個已知缺口、不能改動的 Contract、外部錯誤語意與停止條件;新 Adapter 的名稱和配置方式則留給 Agent 決定。

外部 SDK 不得決定核心 Request/Response,EF Entity 也不能因為方便就成為跨界 Contract。Adapter 可以自行選擇實作細節,但輸入、輸出、錯誤與 Cancellation 的邊界必須能直接讀懂。

N — Non-Surprising Behavior 符合預期:換邊界不能偷偷換掉系統答案

N — Non-Surprising Behavior 符合預期 要求架構調整不能偷偷改變系統答案。本次測試守住 API 回應與資料格式、通知失敗與重試,以及 Cancellation、重複執行和並行處理時的副作用。Port 與 Adapter 可以重新安排,使用者看到的結果不能跟著改變。

今天保留兩條有實際變更壓力的邊界,但 Source Boundary Test 還有掃描盲點

回到標題,Controller、EF Core 與第三方 SDK 不該決定 Use Case,不代表核心永遠不接觸外界,而是它們的型別與語意要在邊界被翻譯。

Controller 應把 HTTP 輸入轉成 Input Port 呼叫;EF Core Entity、DbContext 與查詢細節應留在 Persistence Adapter;SDK Request、Response、Provider Exception 與設定則留在通知 Adapter。Use Case 自己擁有的是 IOverdueWorkItemProcessor、Persistence Port、IWorkItemNotificationSender,以及像 WorkItemNotification 這種能直接表達應用程式需求的資料。

本篇接受通知語意 Adapter 與 Persistence Port,因為兩者都有具體的跨界語意或依賴問題;其他穩定 Framework API 繼續在外層直接使用,沒有為每一個技術 API 增加包裝。

這次結果沒有證明六角形架構適合所有專案,也沒有證明一定節省 Token。更需要處理的是,現有 Source Boundary Test 只掃描指定目錄,還沒涵蓋所有語意上的 Use Case。

接受版本已建立:

git fetch --tags
git switch --detach day-23-clean-boundaries

明天我會故意把 DbContext 注入掃描範圍外的 Processor,檢查測試是否仍然顯示綠燈。這能進一步回答:Dependency Rule 寫得很完整時,測試是否真的找得到所有應受保護的程式碼?

參考資料


上一篇
Day 22|通知 Provider 已有介面,AI 何時才需要加上 Namespace 防線或拆成 Assembly?
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言