安安~我是ChiYu~
昨天我比較了一次重寫與分段 Commit,最後保留可以逐步驗證、失敗時局部還原的清理節奏。函式內部整理完後,我接著往物件邊界看,WorkItemsController 裡還留著幾行很直接的 Code:
item.Assignee = request.Assignee.Trim();
item.Status = "Completed";
workItem.Status = "Overdue";
這些 Setter 很容易讀,卻沒有回答 WorkItem 的角色。它只是由 EF Core 查詢、追蹤與儲存的資料模型,還是也該保護指派、完成與逾期等合法狀態轉換?
這個問題交給 AI Agent 後,只說「改得更物件導向」,Agent 得自行決定哪些 Setter 要關閉、哪些驗證要搬進物件,以及需不需要建立第二套 Domain Model。若明確要求領域模型、資料庫模型與 DTO 完整分離,它也能很快建立三套角色和一組負責資料轉換的 Mapper。
這些版本都可能編譯成功。真正的差異,要等下一項需求進來,才看得出需要修改幾種模型、維護多少同步位置。
今天我先產生三份模型候選,再讓它們分別承受「新增 Approval 業務種類」與「新增改期操作」兩種壓力。完整分離候選在兩種後續需求中,Fresh Token 中位數都是最低;我最後卻沒有選它,而是保留同一個 EF Entity,只收回已經存在的狀態行為。
原因是這次要分開算兩筆帳:Agent 當次完成任務用了多少資源,以及產品未來每次修改都要承擔多少模型同步成本。前者比較低,不代表後者就值得現在開始支付。
物件會隱藏資料的表示方式,讓外部透過有意義的行為操作它。呼叫端知道的是「指派工作」、「完成工作」與「標記逾期」,不需要知道 Status 應該被改成哪一個字串。
資料結構則把資料公開給其他流程讀取、組合或轉換。DTO 就是常見例子,它的責任是把資料帶過 HTTP 邊界,不需要假裝自己擁有領域行為。
兩種設計保護的變更方向不同。放到 Work Item API,可以先看兩種後續需求:
| 變更方向 | Work Item 範例 | 哪種結構通常比較容易承接 |
|---|---|---|
| 新增業務種類 | 由 Standard 擴充 Approval,未來也可能出現 Scheduled 或 Recurring | 各種類有不同欄位、生命週期與行為時,物件與多型比較容易隔離差異 |
| 新增共通操作 | 所有 Work Item 都增加改期、封存或重新開啟 | 資料形狀穩定時,資料結構可以讓新操作直接取得需要的資料 |
這個取捨稱為「資料/物件反對稱性(Data/Object Antisymmetry)」。使用資料結構搭配程序式操作時,新增一項共通操作通常比較直接;加入新的資料種類,既有操作則可能都要跟著調整。物件導向的取捨正好相反:新增實作種類較容易,加入一項橫跨所有種類的操作時,可能要修改多個類別。
它不是在要求我們永遠選物件,而是提醒我們先看最可能發生哪一種變更。
還有一種常見情況:同一個 Class 把資料全部公開,又塞入大量行為。這類混合模型可能同時承擔兩邊的成本,外部可以繞過行為直接改資料,新操作也得先理解類別裡累積的規則。
混合模型不一定有問題。真正需要警覺的是,同一項規則既能被 Setter 繞過,又散落在多個方法裡。此時資料公開沒有換來操作彈性,行為封裝也沒有真的守住不變量。
本篇比較三種模型責任:DTO 負責 HTTP 資料交換;EF Entity/Persistence Model 負責資料表對應、查詢與異動追蹤;Domain Object 則透過行為保護合法狀態與不變量。不變量是物件處於任何合法狀態時,都必須持續成立的規則。
以目前的 Work Item API 來看,可以辨認四種責任:
| 角色 | 主要責任 | 可以公開什麼 | 不適合順便承擔什麼 |
|---|---|---|---|
| Request DTO | 接收 HTTP 輸入 | 對外契約需要的欄位 | EF 追蹤、領域狀態轉換 |
| Response DTO | 輸出 HTTP 結果 | 穩定的 JSON 資料形狀 | 資料庫對應、修改行為 |
| EF Entity/Persistence Model | 對應資料表、查詢與異動追蹤 | Persistence 需要的資料 | 直接成為公開 API 契約 |
| Domain Object | 保護規則與合法狀態轉換 | 有意義的行為 | HTTP Status Code、JSON、DbContext |
四種角色不等於四個 Class,更不代表四個 Project。規則單純、只有一個入口,也沒有跨欄位不變量時,讓 EF Entity 承載少量行為可能更直接。等 API、背景工作或訊息處理都要共用同一套規則,獨立 Domain Object 才可能值得它帶來的 Mapping 成本。
本次基準已經有獨立的 CreateWorkItemRequest、AssignWorkItemRequest 與 WorkItemResponse,所以 EF Entity 沒有直接成為 HTTP Contract。目前共用同一個 WorkItem 的,是 Persistence 與行為:
public sealed class WorkItem
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Priority { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTimeOffset DueAtUtc { get; set; }
public string? Assignee { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
}
目前執行路徑很短:
HTTP DTO → Controller → WorkItem/EF Entity → SQLite
短不代表一定正確,拆開也不會自動比較乾淨。我要驗證的是,下一次新增業務種類或操作時,這條路徑會怎麼改變。
Law of Demeter(迪米特法則)關心呼叫端是否穿過太多物件邊界,知道了不該知道的內部結構。點號數量只能當線索,不能直接當答案。
DTO 的責任本來就包含公開資料,Controller 讀取 request.Assignee 很合理。EF Core 查詢也要讀取 Entity Property,才能翻譯成 SQL。需要警覺的是這種路徑:
workItem.Assignment.Policy.Owner.Contact.Email
Controller 必須理解 Assignment、Policy、Owner 與 Contact 的內部排列,任何一層調整都可能波及它。相較之下,這個呼叫直接表達需求:
workItem.AssignTo(assignee);
目前 Assignee 只是一個字串,還沒有獨立規則或變更壓力。現在就建立額外的 Assignment 物件結構,只會把同一份資料藏得更深。Law of Demeter 要降低不必要的結構知識,沒有要求把每個點號都包成一層方法。
這次只需要 E 與 N:E 要求 User 先交代模型角色與停止條件;N 負責守住重構前後不能改變的外部行為。它們直接決定三份 Prompt 怎麼寫,也決定 Agent 改完後要用什麼標準驗收。
E — Explicit Intent and Boundaries 意圖明確:先命名角色與停止條件一句「改成更物件導向」沒有說明封裝範圍、Persistence 方式與停止點,Agent 只能自行補完。
E — Explicit Intent and Boundaries 意圖明確 要 User 先說清楚三件事:HTTP DTO 已經獨立;EF Entity 仍負責查詢與異動追蹤;目前只封裝 Repository 已經存在的狀態轉換。若找不到第二個 Domain 使用情境,Agent 就應停止新增模型層。
這些資訊不會替 Agent 指定每個方法名稱。它們提供決策邊界,讓 Agent 能在範圍內自主完成實作,碰到沒有證據支持的抽象時就停下來。
N — Non-Surprising Behavior 符合預期:內部角色可以重排,API 不能偷偷換答案模型重構很容易連帶改變預設值、驗證位置、JSON、SQLite Schema、查詢方式或副作用順序。這些改動都可能讓 API 對外行為漂移。
我先定義本篇的 API 邊界 E2E。E2E 是 End-to-End Test,也就是端對端測試:從系統公開入口送入 Request,走過事前定義的執行路徑,最後檢查使用者或其他系統看得到的結果。
本篇的路徑如下:
HTTP Request
→ ASP.NET Core Routing/Model Binding
→ Controller/WorkItem 行為
→ EF Core/SQLite
→ HTTP Response+資料狀態+通知嘗試
測試使用 WebApplicationFactory 啟動 ASP.NET Core 測試主機,再由 HttpClient 發出 HTTP Request。資料寫入獨立的 SQLite 測試資料庫,通知則換成可以記錄呼叫的 RecordingNotificationGateway。
這組 API 邊界 E2E 能檢查 HTTP Status Code、JSON Contract、資料庫最終狀態,以及系統是否發出通知嘗試。它使用測試主機,也沒有真的呼叫外部供應商,因此不是部署環境到真實通知供應商的完整 E2E。把測試起點、終點與替身講清楚,本身就是邊界設計。
三份候選都必須保留相同的 HTTP Contract、SQLite Schema、重跑通知、例外、取消與儲存順序。N — Non-Surprising Behavior 符合預期 允許內部重新分配責任,外部可觀察行為則要保持穩定。
第一階段從同一個 Commit 產生三份模型候選。模型固定為 Codex GPT-5.6-SOL-HIGH,建立、指派、完成、逾期、錯誤路徑與副作用順序也全部固定,刻意改變的是 User 如何描述模型角色。
三種 Prompt 代表三種常見指示:
| 實驗情境 | User 交代什麼 | 想觀察什麼 |
|---|---|---|
| 模糊物件導向 | 只要求更乾淨、更物件導向 | Agent 會自行收進多少責任 |
| 完整分離 | 指定 Domain Object、EF Entity、DTO 與 Mapper 完整分離 | 高隔離設計需要支付多少 Mapping 與同步成本 |
| 角色邊界 | 保留目前 DTO 與 EF Entity,只封裝已有狀態轉換,沒有第二個 Domain 情境時停止 | 情境、禁區與停止點能否限制過度設計 |
完整分離情境即使判斷目前規模不需要,也必須完成最小可運作版本。這個刻意限制很重要,否則 Agent 可能自行退回較簡單的設計,我就量不到完整分離的成本。
角色邊界情境則明確要求:EF Core 的 Where 維持可翻譯的屬性條件,不得先載入整張表再於記憶體篩選,也不得建立第二套 Domain Entity 與雙向 Mapper。
每種描述只產生一份正式候選,因此第一階段的用途是建立三種結構,不能單獨證明差異由 Prompt 造成。第二階段才會固定這三份起點,加入相同後續需求並重複執行,觀察修改路徑與 AI 成本是否穩定。
三份完整 Prompt 都保存在固定版本的 Day 10 物件與資料結構公開 Evidence。
第一份候選沒有新增第二套 Domain Model,也沒有建立 Mapper。Agent 直接把現有 EF Entity 擴成行為物件:七個屬性全部改成 private set,再加入私有建構子、建立方法、AssignTo、Complete 與 MarkOverdue。以下只保留與封裝範圍有關的片段,參數與驗證內容省略。
public sealed class WorkItem
{
private WorkItem()
{
}
public Guid Id { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Priority { get; private set; } = string.Empty;
public string Status { get; private set; } = string.Empty;
public DateTimeOffset DueAtUtc { get; private set; }
public string? Assignee { get; private set; }
public DateTimeOffset CreatedAtUtc { get; private set; }
public static WorkItem Create(/* 省略參數與既有驗證細節 */)
{
// 省略 Title、Priority 與 DueAtUtc 驗證內容
}
}
外部不能任意修改欄位,建立與狀態變更也有單一入口。問題出在封裝範圍超過目前證據:HTTP 層為了維持既有錯誤格式與中文訊息,仍要保留 Title、Priority 與 DueAtUtc 驗證,Model 又多放了一份相近規則,形成兩個同步位置。
如果建立規則未來會被 API、批次匯入與背景工作共同使用,把它收進物件就有機會避免重複。現在只有 Controller 這條入口,模糊 Prompt 讓 Agent 提前替 User 決定了建立與 Persistence 邊界。
這份候選不是因為「比較物件導向」就錯,而是它收進物件的責任,已經超過目前 Repository 能證明的共用需求。
第二份候選按照 Prompt 建立 Domain Object、EF Entity 與既有 DTO,另外新增 Mapper,在兩套模型之間轉換資料,讓 Domain 與 Persistence 可以分開演進。
HTTP Request DTO
│
▼
Domain WorkItem ←雙向 Mapping→ WorkItemEntity ←→ SQLite
│
└────────單向 Mapping────────→ HTTP Response DTO
Agent 建立四個 Mapping 方法:
public static WorkItem ToDomainObject(
this WorkItemEntity persistenceModel);
public static WorkItemEntity ToPersistenceModel(
this WorkItem workItem);
public static void ApplyTo(
this WorkItem workItem,
WorkItemEntity persistenceModel);
public static WorkItemResponse ToResponse(
this WorkItem workItem);
建立流程使用 ToPersistenceModel(),把 Domain Object 轉成新的 Persistence Model。指派、完成與逾期流程則先用 ToDomainObject() 建立 Domain Object,執行行為,再透過 ApplyTo() 把結果同步回原本的 tracked Entity;ToResponse() 另外負責 HTTP 輸出。
完整分離很適合下面幾種情境:
目前 Work Item API 只有一個 HTTP 使用情境,也只有少量字串狀態轉換。真正需要長期維護的是 Domain 與 Entity 的雙份欄位表示,以及建立、載入與回寫三個轉換方向。這些同步位置還沒有隔離到真實變更,成本暫時回收不了。
完整分離的價值很清楚,問題只在時機。現在就採用,等於先替還沒有出現的第二個使用情境付費。
第三份候選沒有新增正式程式碼類別,也沒有建立第二套 Domain Model。WorkItem 繼續由 EF Core 追蹤,只把 Status 與 Assignee 的 Setter 關起來:
public sealed class WorkItem
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Priority { get; set; } = string.Empty;
public string Status { get; private set; } = "Open";
public DateTimeOffset DueAtUtc { get; set; }
public string? Assignee { get; private set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public void AssignTo(string assignee)
{
Assignee = assignee.Trim();
}
public void Complete()
{
Status = "Completed";
}
public bool MarkOverdue()
{
if (Status == "Overdue")
{
return false;
}
Status = "Overdue";
return true;
}
}
Controller 不再直接寫入狀態:
item.AssignTo(request.Assignee);
item.Complete();
var overdueStatusChanged = workItem.MarkOverdue();
這份候選只收回 Repository 已存在的三項狀態轉換。Title、Priority、DueAtUtc 與 CreatedAtUtc 目前仍是 Persistence 需要的資料,沒有被一起封閉。
MarkOverdue() 目前只避免重複標記,沒有自行檢查 Completed。原因是現有 Controller 查詢已先排除完成項目,而且目前沒有第二個呼叫入口。等背景工作或其他流程也能直接呼叫它,再把「Completed 不得轉為 Overdue」提升為物件不變量。
這個停止點很重要:不是能封裝的欄位都先關起來,而是只保護目前已經存在、也會被呼叫端繞過的行為。
EF Core 支援私有 Setter,也能使用符合 Mapping 條件的建構子。這讓 Entity 保留部分封裝時,仍可以被載入與追蹤。實際支援方式仍要依 EF Core Entity Type Constructors 官方文件與整合測試確認。
把行為移進物件時,還有一條很容易被 Agent 踩過去的技術邊界。
EF Core 的 Where 條件必須能由資料來源轉譯器(Provider)轉成 SQL,所以查詢裡的 C# 不能一律當成記憶體迴圈處理。
如果 Agent 為了隱藏條件,直接改成:
var items = await database.WorkItems
.Where(item => item.ShouldBeProcessedAsOverdue(now))
.ToListAsync(cancellationToken);
自訂實例方法若沒有對應的 Provider 翻譯,放進 Where 時通常無法轉成 SQL。現代 EF Core 在這類位置遇到無法翻譯的運算式時會拋出例外;若改成先 ToListAsync() 再於記憶體篩選,又可能把不需要的資料整批載回應用程式。
因此,資料庫端先保留可翻譯的屬性條件:
var items = await database.WorkItems
.Where(item =>
item.Status != "Completed" &&
item.DueAtUtc <= now)
.ToListAsync(cancellationToken);
資料載入後,再呼叫物件行為:
var overdueStatusChanged = workItem.MarkOverdue();
Clean Code 要降低理解與修改成本,也要尊重執行環境。為了把每一條判斷藏進方法,反而讓資料庫查詢失去翻譯能力,這筆交換不划算。詳細限制可以對照 EF Core Client/Server Evaluation 官方文件。
三份第一階段候選完成後,我由實驗主流程重新驗證相同的 HTTP 契約、資料狀態與副作用。這裡關心的內容如下:
| 行為邊界 | 驗收內容 |
|---|---|
| HTTP Contract | Route、Method、Status Code、Validation Problem 與 Response JSON 維持不變 |
| Persistence | SQLite Schema 不變,資料可以正確儲存與重新載入 |
| 既有流程 | 建立、指派、完成與逾期結果一致 |
| 副作用 | 通知失敗、例外、取消、重跑通知與儲存順序維持原本語意 |
三份候選全部通過。綠燈只能確認已定義行為沒有漂移,無法替我們選出長期修改成本最低的模型。
第一階段也只看得到現在。資料/物件反對稱性真正關心的是下一種修改,所以我又替三份候選各準備兩項後續需求。
第一項需求加入 Approval 類型:它和 Standard 共用大部分資料,但多了一條「尚未指派就不能完成」的規則。這能測試新增業務種類時,資料欄位、行為與 Mapping 需要同步幾份。
第二項需求替所有 Work Item 增加改期操作。DueAtUtc 已經存在,可以用來觀察新增共通操作時要穿過哪些模型角色。
三份結構、兩種壓力,每組各執行三次,總共是 3 × 2 × 3 = 18 次彼此隔離的 Agent 執行。模型、起始 Commit 與 Prompt 內容固定,Agent 也不能把候選改造成另外兩種架構。
重複三次的目的,是觀察修改落點與 AI 成本會不會在生成變異下維持相近。三次仍然是小樣本,足以看見本 Repository 的現象,不能直接外推成所有 .NET 專案的固定結果。
Fresh Token 是本篇用來觀察當次新增 AI 用量的指標,計算未命中快取的 Input Token 加上 Output Token。Tool Call 則是 Agent 搜尋、讀檔、修改與執行驗證時呼叫工具的次數。兩者都只能比較已通過相同行為驗收的結果。
我依照這個順序判斷每份輸出:
Approval 需求固定以下行為:
新增 Standard/Approval 兩種 kind,未提供時維持 Standard。
無效 kind 回傳既有 Validation Problem 與固定中文訊息。
Response 與 SQLite 都要儲存 kind。
未指派的 Approval 不得完成,回傳 409,也不得寫成 Completed。
指派後可以完成;Standard 的既有行為全部保持。
不得把候選改造成另一種 Model 分工。
這項需求沒有指定繼承或 Design Pattern。九次輸出最後都選擇加入 WorkItemKind 列舉(enum)與一條完成條件,沒有建立 StandardWorkItem、ApprovalWorkItem 或繼承樹。
if (Kind == WorkItemKind.Approval && string.IsNullOrWhiteSpace(Assignee))
{
return false;
}
目前只有一項 Approval 專屬規則,列舉加條件分支比較容易驗證。等不同種類真的擁有不同欄位、生命週期或多項行為,再考慮多型會比較合理。
三份候選的主要差異如下:
| 候選 | Approval 需要穿過的角色 | 三次執行的檔案配置 | AI 成本觀察 |
|---|---|---|---|
| 模糊物件導向 | DTO、EF/行為 Model、DbContext、Controller |
WorkItemKind 放在既有 Model 或獨立檔案 |
Fresh Token 中位數最高 |
| 完整分離 | DTO、Domain、EF Entity、DbContext、Mapper、Controller |
WorkItemKind 放在既有 Domain Model 或獨立檔案 |
穿過最多角色,Fresh Token 中位數卻最低 |
| 角色邊界 | DTO、EF/行為 Model、DbContext、Controller |
WorkItemKind 放在既有 Model 或獨立檔案 |
Fresh Token 中位數介於兩者之間 |
完整分離要在 Domain Object 與 Persistence Entity 各保留一份 kind,Mapper 也要補上來回轉換。這些同步點會跟著產品長期存在。
另一方面,它的 Fresh Token 中位數最低。我的推測是,完整分離的角色名稱讓 Agent 較快判斷欄位應該放在哪裡,所以 Class 與 Mapping 較多,不會直接推導出 Token 較高。
三組 Token 範圍大量重疊,三種候選也都出現兩種 WorkItemKind 檔案配置。這組小樣本還不足以宣稱哪一種模型能穩定節省 Token。

圖:新增 Approval 類型時,完整分離版本要同步 Domain、Entity 與 Mapping;角色邊界版本沿用同一個受追蹤 Model。
第二項需求刻意沿用既有 DueAtUtc,只替所有 Work Item 增加改期操作:
新增 PUT /api/work-items/{id:guid}/due-at。
Request 以新的 DTO 接收 dueAtUtc。
預設時間回傳固定 Validation Problem,找不到資料回傳 404。
成功時只更新 DueAtUtc,其他欄位、通知與既有行為不得改變。
SQLite Schema 不變,必須驗證真實 EF Tracking 與資料重新載入。
不得把候選改造成另一種 Model 分工。
三份候選都修改三個正式程式碼檔案,主要是 Request DTO、Controller 與 Model/Domain。只看檔案數量分不出差異,實際更新路徑才是重點:
| 候選 | 改期的執行路徑 | 三次輸出觀察 | AI 成本觀察 |
|---|---|---|---|
| 模糊物件導向 | DTO → Controller → tracked Model → SQLite | 三次都修改相同責任組合 | Fresh Token 中位數最高 |
| 完整分離 | DTO → Controller → EF Entity → Domain → EF Entity → SQLite | 三次都修改相同責任組合 | Fresh Token 中位數最低 |
| 角色邊界 | DTO → Controller → tracked Model → SQLite | 三次都修改相同責任組合 | Tool Call 中位數最低,Fresh Token 接近完整分離 |
完整分離既有 Mapper 已經能處理 DueAtUtc,所以這次不用再修改 Mapper 檔案。執行時仍要把 Entity 轉成 Domain Object,呼叫 Reschedule(),再把結果套回 tracked Entity:
var workItem = entity.ToDomainObject();
workItem.Reschedule(request.DueAtUtc);
workItem.ApplyTo(entity);
await database.SaveChangesAsync(cancellationToken);
角色邊界與模糊候選由 EF Core 直接追蹤同一個行為 Model,呼叫 Reschedule() 後就能儲存 DueAtUtc 的變化,執行路徑較短。
這組 Token 差異比 Approval 集中,完整分離的最高值仍低於模糊候選的最低值。不過每組只有三次,而且 Token 也包含讀取 Repository 規範、Skill 與工具重試的成本。我只能說明本次 Repository 觀察到的結果,無法換算成其他專案可以節省多少 Token。
18 份輸出都通過實驗主流程的行為驗收,過程沒有人工修改 Code。完整 Prompt、每次 Commit、指標與 Diff 另外收在固定版本的 Day 10 新增業務種類/操作與 AI 成本公開 Evidence。

圖:新增共通改期操作時,完整分離版本仍需在 Domain 與 Entity 間往返,其他兩種結構的執行路徑較短。
跑完兩階段後,本系列接續角色邊界版本。理由有三項:
Status 與 Assignee 已有指派、完成與逾期行為,其餘欄位還沒有獨立不變量。目前只有單一入口、少量狀態轉換,而且 EF Core 能直接查詢與追蹤,所以我接受角色邊界版本。
完整分離在本次兩種壓力下,Fresh Token 中位數都最低;這項結果沒有改變我的選擇。一次 Agent 執行用量與每次產品修改都要承擔的 Entity/Domain 同步,是兩筆不同的帳。完整分離要等第二個 Domain 使用情境、不同生命週期或 Persistence 差異真的出現,再支付雙模型同步成本。
角色邊界也沒有變成通用答案。三種方向各有適合的條件:
| 專案情境 | 優先考慮的模型方向 | 原因 |
|---|---|---|
| 單一入口、規則單純、EF 角色穩定 | 角色邊界 | 只保護已有行為,維持直接查詢與追蹤 |
| 建立規則與多項狀態轉換需要共同保護 | 較完整的行為物件 | 避免不同入口各自複製規則 |
| 多入口、Domain 與 Schema 分歧,或 Persistence 需要替換 | Domain/Persistence 完整分離 | Mapper 開始隔離真實存在的變更速度 |
若系統長期只有資料處理操作,幾乎沒有需要保護的不變量,保持資料結構搭配程序式操作也可能更直接。Clean Code 提供的是選擇模型的判準,沒有要求所有資料表都先長出一套 Domain Model。
前面幾篇已經陸續把實驗判準整理成 Repository Policy。現在老樣子~我把這次校準出的條件整理成一份可放進 AGENTS.md 的 Model Role Policy,避免每次都逐一指定哪個 Setter 要關、哪個 Mapper 不要建。
Model Role Policy 是 Repository 對模型責任、升級條件、技術邊界與完成回報的共同規則:
## Model Role Policy
### Role
- Request/Response DTO 只負責 HTTP 資料交換,不承擔 EF Tracking 或領域狀態轉換。
- EF Entity 負責資料表對應、查詢與 Change Tracking,不得直接作為公開 API Contract。
- 只有已存在且需要保護的合法狀態轉換,才優先收回物件方法。
### Upgrade Trigger
- 新增 Domain/Persistence 分離前,先列出第二個 Domain 使用情境、需要隔離的變更來源,以及 Mapping 同步成本。
- 沒有跨欄位不變量時,不為展示封裝新增包裝型別或第二套 Domain Entity。
- 當 Agent 建議新增模型層、Mapper 或 Domain/Persistence 分離時,分別提出一項可能的「新增業務種類」與「新增操作」,列出修改位置與執行路徑。
### Technical Boundary
- IQueryable 篩選必須維持可由 Provider 翻譯,不得無理由載入全表後再於記憶體篩選。
- HTTP Contract、Database Schema、錯誤語意與副作用順序屬於獨立邊界,重構 Model 時不得順便修改。
### Completion Report
- 完成後回報新增 Model、Mapper、同步點、查詢位置、整合測試與停止理由。
- 先通過行為驗收,再用 Token、Tool Call 與時間比較同品質候選。
這份 Policy 不能保證 Agent 每次都選中團隊偏好的模型,也不能保證每次都省 Token。它會限制幾種高成本誤判:把 DTO 當 Domain、直接輸出 EF Entity、為單純 CRUD 建立多套 Model,以及把無法翻譯的方法放進資料庫查詢。
本文先用 AGENTS.md 完整示範這份規則;實際加入 Repository 後,下一個 Agent 才會自動讀到。等規則穩定且需要跨專案重複使用時,再抽成獨立 SKILL。
本次基準 Commit 是 995449b5617fb0606cf9554eaf46707ba53ef401,Annotated Tag 為 day-10-objects-data-structures-baseline。
如果你已經 Clone AI-CleanCode-API-Demo,可以切到相同起點:
git fetch origin --tags
git switch --detach day-10-objects-data-structures-baseline
git rev-parse HEAD
最後一行應顯示:
995449b5617fb0606cf9554eaf46707ba53ef401
本系列接受版本的 Commit 是 6693431f2bc917b4cdeeacaade4b94d3be8a03e0,Annotated Tag 為 day-10-objects-data-structures:
git switch --detach day-10-objects-data-structures
git rev-parse HEAD
最後一行應顯示:
6693431f2bc917b4cdeeacaade4b94d3be8a03e0
這兩個指令都會進入 detached HEAD,不會移動原本的本地 Branch。
三份第一階段候選與第二階段 18 次執行的 Prompt、Agent 最終回覆、Commit、主流程驗證與完整指標,分別放在:
回到開頭,我沒有因為完整分離的 Fresh Token 中位數較低,就替目前的 API 增加第二套模型。
WorkItem 現在只收回已存在的指派、完成與逾期行為;DTO 繼續守住 HTTP 契約,EF Entity 繼續負責查詢與追蹤。這個選擇不是反對 Domain Model,而是不在第二個使用情境出現以前,先支付雙模型與 Mapping 的同步成本。
E — Explicit Intent and Boundaries 意圖明確 讓 User 先說清楚模型角色與停止條件,N — Non-Surprising Behavior 符合預期 則守住不能漂移的外部行為。等 Work Item 出現不同生命週期、多個入口或獨立 Persistence,再用相同的「新增業務種類/新增操作」壓力重新驗收。
單一 WorkItem 的資料與行為邊界暫時清楚了,Controller 卻仍同時處理建立、指派、完成與整批逾期。明天要往上看整個 Class:類別低於 100 行,真的就只剩一個責任嗎?