--- title: 從踩坑到重構 — 拆分 API 合約與 BO 參數 tags: [dotnet, architecture, api-design, Bee.NET, clean-architecture] --- # 從踩坑到重構 — 拆分 API 合約與 BO 參數 > 分享我在開發 Bee.NET 框架的過程中,一路踩坑、反覆調整後摸索出的設計方式。如果你也有類似的困擾,或許可以參考看看。 ![api-bo-contract](https://hackmd.io/_uploads/S1NXBMc3Wl.png) # 1️⃣ 我踩過的坑 在開發 [Bee.NET](https://github.com/jeff377/bee-library) 框架的早期版本時,為了圖方便,我讓 API 端點的 Request / Response 物件和業務邏輯層(Business Object, BO)的參數物件共用同一個類別。一個 `LoginRequest` 從 API 層傳到 BO 層,一路暢通,開發起來很快,但完全沒顧及後續的擴展性。 但隨著框架演進,幾個讓我越來越頭痛的問題開始浮現: - **序列化標記污染 BO 層**:`[MessagePackObject]`、`[Key(n)]` 這些只有 API 傳輸才需要的 Attribute,跟著混進了業務邏輯層,讓 BO 程式碼看起來莫名其妙 - **BO 專用屬性暴露給用戶端**:API 與 BO 共用同一個型別,導致用戶端也看得到只有 BO 才會用到的屬性,合約邊界不清晰 - **BO 無法自由擴充**:我想在 BO 間傳遞一個 `IsAutoLogin` 旗標,卻發現加上去就會暴露在 API 合約中 - **測試耦合**:測試 BO 邏輯時,還得處理序列化相關的相依性,明明跟測試目標無關 回頭看,這些問題的根源都一樣:**API 傳輸關注點與業務邏輯關注點全擠在同一個型別裡**。 --- # 2️⃣ 改善方式:合約介面作為唯一真實來源 經過幾次重構嘗試,我最終在 Bee.NET 中採用了**合約介面(Contract Interface)**的做法:定義一個介面作為屬性的唯一真實來源,API 型別和 BO 型別各自實作這個介面。 ``` 合約介面(ILoginRequest / ILoginResponse) ← 定義共用屬性,唯一真實來源 │ ├── API 型別(LoginRequest / LoginResponse) ← 含序列化標記,用於 API 傳輸 │ └── BO 型別(LoginArgs / LoginResult) ← 純 POCO,用於業務邏輯 ``` 這個做法解決了前面提到的幾個痛點: 1. **用戶端只接觸 API 型別**,不會看到 BO 專用的屬性 2. **BO 層不依賴 API 組件**,可以獨立測試與演進 3. **BO 可在合約之外新增內部專用屬性**,不會污染 API 合約 --- # 3️⃣ 型別分層實作 ### 第一層:合約介面 定義在 `Bee.Api.Contracts` 組件中,只包含唯讀屬性,不含任何序列化標記。這個專案的角色類似於 `Bee.Repository.Abstractions`,作為層與層之間的純介面合約: ```csharp public interface ILoginRequest { string UserId { get; } string Password { get; } string ClientPublicKey { get; } } public interface ILoginResponse { Guid AccessToken { get; } DateTime ExpiredAt { get; } string ApiEncryptionKey { get; } string UserId { get; } string UserName { get; } } ``` ### 第二層:API 合約型別 定義在 `Bee.Api.Core` 組件中,繼承框架基底類別,實作合約介面,並標記 MessagePack 序列化屬性: ```csharp [MessagePackObject] [Serializable] public class LoginRequest : ApiRequest, ILoginRequest { [Key(100)] public string UserId { get; set; } = string.Empty; [Key(101)] public string Password { get; set; } = string.Empty; [Key(102)] public string ClientPublicKey { get; set; } = string.Empty; } ``` > `[Key(n)]` 從 100 開始,避免與基底類別的保留欄位衝突。 ### 第三層:BO 參數型別 定義在 `Bee.Business` 組件中,同樣實作合約介面,但是純 POCO — 沒有序列化標記,乾淨俐落: ```csharp public class LoginArgs : BusinessArgs, ILoginRequest { public string UserId { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string ClientPublicKey { get; set; } = string.Empty; // BO 專用屬性 — 不在合約介面中,API 層看不到 public bool IsAutoLogin { get; set; } } ``` --- # 4️⃣ 三種使用情境 實務上不是每個 API 方法都需要建立完整三層型別。在 Bee.NET 的開發經驗中,我歸納出三種常見情境: ### 情境一:API 方法,BO 不需額外屬性(最常見) API 與 BO 的參數完全相同,不需建立 Args / Result 型別。BO 方法直接使用合約介面: ```csharp public ILoginResponse Login(ILoginRequest request) { return new LoginResponse { AccessToken = Guid.NewGuid(), UserId = request.UserId }; } ``` ### 情境二:API 方法,BO 需要額外屬性 BO 間互相呼叫時,需要傳遞 API 不可見的內部屬性。此時額外建立 Args 型別,BO 方法透過模式比對取得額外屬性: ```csharp public ILoginResponse Login(ILoginRequest request) { bool isAutoLogin = request is LoginArgs args && args.IsAutoLogin; // ... } ``` 外部 API 呼叫傳入 `LoginRequest`,BO 內部呼叫傳入 `LoginArgs`,同一個方法簽章兼容兩者。 ### 情境三:BO 內部方法(不公開至 API) 僅 BO 內部使用的方法,不需合約介面也不需 API 型別,直接定義 BO 參數即可: ```csharp public class RecalcArgs : BusinessArgs { public string OrderId { get; set; } public bool ForceRecalc { get; set; } } ``` --- # 5️⃣ 命名慣例與組件相依 ## 命名慣例速查 | 用途 | 命名模式 | 範例 | 所在組件 | |------|----------|------|----------| | 合約介面(輸入) | `IXxxRequest` | `ILoginRequest` | Bee.Api.Contracts | | 合約介面(輸出) | `IXxxResponse` | `ILoginResponse` | Bee.Api.Contracts | | API 輸入 | `XxxRequest` | `LoginRequest` | Bee.Api.Core | | API 輸出 | `XxxResponse` | `LoginResponse` | Bee.Api.Core | | BO 輸入 | `XxxArgs` | `LoginArgs` | Bee.Business | | BO 輸出 | `XxxResult` | `LoginResult` | Bee.Business | ## 組件相依方向 ``` Bee.Api.Contracts ← 合約介面(IXxxRequest / IXxxResponse) │ ├── Bee.Api.Core ← API 型別(含序列化) │ │ │ └── Bee.Api.Client ← 用戶端(只用 Request / Response) │ └── Bee.Business ← BO 型別(純 POCO)+ BO 介面 ``` 關鍵原則:`Bee.Api.Core` 與 `Bee.Business` **彼此不相依**,各自只依賴 `Bee.Api.Contracts`(純介面合約)。這確保了 API 傳輸層的變動(例如換掉序列化框架)不會影響 BO 層,反之亦然。 --- # 6️⃣ 實戰步驟:新增一個 API 方法 以新增 `GetOrder` 為例,完整步驟如下: 1. **定義合約介面**(`Bee.Api.Contracts`)— `IGetOrderRequest`、`IGetOrderResponse` 2. **建立 API 型別**(`Bee.Api.Core`)— `GetOrderRequest`、`GetOrderResponse`,標記 MessagePack 3. **實作 BO 方法** — 簽章使用 `IGetOrderRequest` / `IGetOrderResponse` 4. **註冊回應映射**(若 BO 回傳純 POCO)— `ApiContractRegistry.Register<IGetOrderResponse, GetOrderResponse>()` 5. **更新用戶端 Connector**(若需要)— 在 Connector 中新增方法 --- # ✅ 結語 回顧整個改善過程,核心思想其實很簡單:**讓每一層只關心自己該關心的事**。 API 型別負責序列化與傳輸格式,BO 型別負責承載業務邏輯所需的資料,合約介面則作為兩者之間的橋樑,確保屬性定義只寫一次。 確實多寫了一些型別,但對我來說換來的好處是值得的: - BO 層可以獨立測試,不需處理序列化相依性 - API 合約變更不會意外影響業務邏輯 - 用戶端只需引用最小的組件集合 - 可以平行開發 API 層與 BO 層,互不阻塞 這個做法不一定適合所有專案,對於小型專案來說可能會覺得多此一舉。但如果你的 .NET 專案也開始感受到 API 型別與業務型別糾纏不清的痛苦,或許可以參考這個方向,依自己的需求取捨。 ## 延伸閱讀 - **完整設計文件** [API/BO 合約設計原則 — Bee.NET](https://github.com/jeff377/bee-library/blob/main/docs/api-bo-contract-design.zh-TW.md) - **[Bee.NET](https://github.com/jeff377/bee-library) 框架** [GitHub — Bee.NET Library](https://github.com/jeff377/bee-library) --- 📘 **HackMD 原文筆記:** 👉 https://hackmd.io/@jeff377/api-bo-contract **📢 歡迎轉載,請註明出處** **📬 歡迎追蹤我的技術筆記與實戰經驗分享** [Facebook](https://www.facebook.com/profile.php?id=61574839666569) | [HackMD](https://hackmd.io/@jeff377) | [GitHub](https://github.com/jeff377) | [NuGet](https://www.nuget.org/profiles/jeff377)