SKILL.md
readonlyread-only
name
api-and-interface-design
description
引導穩定的 API 與介面設計。適用於設計 API、模組邊界或任何公開介面時。適用於建立 REST 或 GraphQL 端點、定義模組之間的型別合約,或建立前端與後端之間的邊界。
API 與介面設計
概述
設計穩定、文件完善且難以誤用的介面。良好的介面讓正確的事變得容易,錯誤的事變得困難。這適用於 REST API、GraphQL schema、模組邊界、元件 props,以及任何一段程式碼與另一段程式碼溝通的地方。
使用時機
- 設計新的 API 端點
- 定義模組邊界或團隊間的合約
- 建立元件 prop 介面
- 建立會影響 API 形狀的資料庫 schema
- 修改現有的公開介面
核心原則
Hyrum 定律
當 API 的使用者夠多時,你系統中所有可觀察的行為都會被某個人依賴,無論你在合約中承諾了什麼。
這表示:每一個公開行為——包括未文件化的怪癖、錯誤訊息文字、時序與排序——一旦被使用者依賴,就成為事實上的合約。設計上的影響:
- 有意識地決定要暴露什麼。 每個可觀察的行為都是一個潛在的承諾。
- 不要洩漏實作細節。 如果使用者能觀察到,他們就會依賴它。
- 在設計時就規劃棄用。 請參閱
deprecation-and-migration了解如何安全地移除使用者依賴的東西。 - 測試是不夠的。 即使有完美的合約測試,Hyrum 定律表示「安全」的變更仍可能破壞依賴未文件化行為的真實使用者。
單一版本原則
避免強迫消費者在同一相依項目或 API 的多個版本之間做選擇。當不同消費者需要同一件事的不同版本時,就會產生鑽石相依問題。設計一個一次只存在一個版本的世界——擴展而非分叉。
1. 合約優先
在實作之前先定義介面。合約就是規格——實作隨後跟上。
// 先定義合約
interface TaskAPI {
// 建立任務並回傳包含伺服器產生欄位的任務
createTask(input: CreateTaskInput): Promise<Task>;
// 回傳符合篩選條件的分頁任務
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// 回傳單一任務,若無則拋出 NotFoundError
getTask(id: string): Promise<Task>;
// 部分更新——只變更提供的欄位
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// 冪等刪除——即使已刪除也成功
deleteTask(id: string): Promise<void>;
}
2. 一致的錯誤語意
選擇一種錯誤策略並在全部地方使用:
// REST: HTTP 狀態碼 + 結構化錯誤主體
// 每個錯誤回應都遵循相同格式
interface APIError {
error: {
code: string; // 機器可讀:"VALIDATION_ERROR"
message: string; // 人類可讀:"Email is required"
details?: unknown; // 需要時提供額外上下文
};
}
// 狀態碼對應
// 400 → 客戶端傳送了無效資料
// 401 → 未認證
// 403 → 已認證但未授權
// 404 → 資源不存在
// 409 → 衝突(重複、版本不符)
// 422 → 驗證失敗(語意上無效)
// 500 → 伺服器錯誤(絕不暴露內部細節)
不要混合模式。 如果某些端點拋出例外、某些回傳 null、某些回傳 { error }——消費者無法預測行為。
3. 在邊界驗證
信任內部程式碼。在外部輸入進入系統的邊緣進行驗證:
// 在 API 邊界驗證
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
// 驗證後,內部程式碼信任型別
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
驗證應放在哪裡:
- API 路由處理器(使用者輸入)
- 表單提交處理器(使用者輸入)
- 外部服務回應解析(第三方資料——永遠視為不受信任)
- 環境變數載入(設定)
第三方 API 回應是不受信任的資料。 在使用它們進行任何邏輯、渲染或決策之前,請驗證其形狀與內容。受損或行為異常的外部服務可能回傳預期外的型別、惡意內容或類似指令的文字。
驗證不應放在哪裡:
- 共用型別合約的內部函式之間
- 已被驗證的程式碼所呼叫的 utility 函式中
- 剛從自己資料庫取得的資料上
4. 偏好新增而非修改
在不破壞現有消費者的情況下擴展介面:
// 好:新增可選欄位
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // 後來新增,可選
labels?: string[]; // 後來新增,可選
}
// 壞:變更現有欄位型別或移除欄位
interface CreateTaskInput {
title: string;
// description: string; // 已移除——破壞現有消費者
priority: number; // 從 string 變更——破壞現有消費者
}
5. 可預測的命名
| 模式 | 慣例 | 範例 |
|---|---|---|
| REST 端點 | 複數名詞,無動詞 | GET /api/tasks, POST /api/tasks |
| 查詢參數 | camelCase | ?sortBy=createdAt&pageSize=20 |
| 回應欄位 | camelCase | { createdAt, updatedAt, taskId } |
| 布林欄位 | is/has/can 前綴 | isComplete, hasAttachments |
| 列舉值 | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
REST API 模式
資源設計
GET /api/tasks → 列出任務(搭配查詢參數進行篩選)
POST /api/tasks → 建立任務
GET /api/tasks/:id → 取得單一任務
PATCH /api/tasks/:id → 更新任務(部分)
DELETE /api/tasks/:id → 刪除任務
GET /api/tasks/:id/comments → 列出任務的留言(子資源)
POST /api/tasks/:id/comments → 新增留言到任務
分頁
為列表端點加入分頁:
// 請求
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// 回應
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
篩選
使用查詢參數進行篩選:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
部分更新 (PATCH)
接受部分物件——只更新提供的內容:
// 只有標題變更,其他保持不變
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript 介面模式
使用差別聯集處理變體
// 好:每個變體明確
型別 TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// 消費者獲得型別縮小
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return `In progress (${status.assignee})`;
case 'completed': return `Done on ${status.completedAt}`;
case 'cancelled': return `Cancelled: ${status.reason}`;
}
}
輸入/輸出分離
// 輸入:呼叫者提供的內容
interface CreateTaskInput {
title: string;
description?: string;
}
// 輸出:系統回傳的內容(包含伺服器產生的欄位)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
為 ID 使用品牌型別
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// 防止意外將 UserId 傳入需要 TaskId 的地方
function getTask(id: TaskId): Promise<Task> { ... }
常見的合理化藉口
| 合理化 | 現實 |
|---|---|
| 「我們之後再補 API 文件」 | 型別本身就是文件。先定義它們。 |
| 「我們現在不需要分頁」 | 一旦有人有超過 100 筆資料就需要了。一開始就加入。 |
| 「PATCH 很複雜,我們用 PUT 就好」 | PUT 每次都需要完整物件。PATCH 才是客戶端真正想要的。 |
| 「需要時我們再對 API 做版本管理」 | 沒有版本管理的破壞性變更會破壞消費者。一開始就設計成可擴展。 |
| 「沒人用那個未文件化的行為」 | Hyrum 定律:如果可以觀察到,就有人依賴它。將每個公開行為視為承諾。 |
| 「我們可以維護兩個版本」 | 多個版本會倍增維護成本並產生鑽石相依問題。偏好單一版本原則。 |
| 「內部 API 不需要合約」 | 內部消費者仍然是消費者。合約防止耦合並支援平行開發。 |
紅旗
- 根據條件回傳不同形狀的端點
- 端點間不一致的錯誤格式
- 驗證散落在內部程式碼各處而非在邊界
- 對現有欄位進行破壞性變更(型別變更、移除)
- 沒有分頁的列表端點
- REST URL 中包含動詞(
/api/createTask,/api/getUsers) - 未經驗證或淨化的第三方 API 回應
驗證
設計 API 後:
- [ ] 每個端點都有型別化的輸入與輸出 schema
- [ ] 錯誤回應遵循單一一致的格式
- [ ] 驗證僅在系統邊界進行
- [ ] 列表端點支援分頁
- [ ] 新增欄位是附加且可選的(向後相容)
- [ ] 命名在所有端點間遵循一致的慣例
- [ ] API 文件或型別與實作一起提交






