api-and-interface-design

api-and-interface-design

熱門

引導穩定的 API 與介面設計。適用於設計 API、模組邊界或任何公開介面時。適用於建立 REST 或 GraphQL 端點、定義模組之間的型別合約,或建立前端與後端之間的邊界。

7.7萬星標
0分支
更新於 2026/7/3
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 文件或型別與實作一起提交