---
title: 從踩坑到重構 — 拆分 API 合約與 BO 參數
tags: [dotnet, architecture, api-design, Bee.NET, clean-architecture]
---
# 從踩坑到重構 — 拆分 API 合約與 BO 參數
> 分享我在開發 Bee.NET 框架的過程中,一路踩坑、反覆調整後摸索出的設計方式。如果你也有類似的困擾,或許可以參考看看。

# 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)