# API And Interface Design

> 指导稳定的 API 与接口设计。适用于设计 API、模块边界或任何公开接口时。也适用于创建 REST 或 GraphQL endpoint、定义模块之间的类型契约，或建立前后端边界的时候。

- Skill: `233i/api-and-interface-design` (Agent Skill)
- Install (CLI): `npx skillmds@latest add 233i/api-and-interface-design`
- Raw SKILL.md: https://api.skillmd.com/api/skills/233i/api-and-interface-design/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: 233i (https://skillmd.com/u/233i)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/233i/api-and-interface-design

---


# API 与接口设计

## 概览

设计稳定、文档完备且不容易被误用的接口。好的接口会让正确用法变得容易，让错误用法变得困难。这适用于 REST API、GraphQL schema、模块边界、组件 props，以及任何一段代码与另一段代码对话的表面。

## 何时使用

- 设计新的 API endpoint
- 定义模块边界或团队之间的契约
- 设计组件 prop 接口
- 制定会影响 API 形状的数据库 schema
- 修改现有公开接口

## 核心原则

### 海勒姆定律（Hyrum's Law）

> With a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody, regardless of what you promise in the contract.

这句话的意思是：只要用户足够多，系统里每一个可观察行为，哪怕是没写进文档的怪癖、错误文案、时序和顺序，都会被某些人依赖上。一旦有人依赖，它就变成了事实契约。因此：

- **暴露什么必须是有意识的。** 每一个可观察行为都可能成为长期承诺
- **不要泄露实现细节。** 用户能观察到，就会依赖它
- **在设计时就考虑弃用。** 如何安全移除已被依赖的东西，见 `deprecation-and-migration`
- **测试不够。** 即使有完美的契约测试，Hyrum's Law 仍意味着“看似安全”的改动也可能打断真实用户

### 单版本原则（One-Version Rule）

尽量避免让消费者在同一个依赖或 API 的多个版本之间做选择。不同消费者依赖不同版本时，会形成经典的菱形依赖问题。设计时要尽量假设“同一时间只存在一个版本”，优先扩展而不是分叉。

### 1. 先定义契约

先定义接口，再实现它。契约本身就是 spec，实现应该跟着契约走。

```typescript
// Define the contract first
interface TaskAPI {
  // Creates a task and returns the created task with server-generated fields
  createTask(input: CreateTaskInput): Promise<Task>;

  // Returns paginated tasks matching filters
  listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;

  // Returns a single task or throws NotFoundError
  getTask(id: string): Promise<Task>;

  // Partial update — only provided fields change
  updateTask(id: string, input: UpdateTaskInput): Promise<Task>;

  // Idempotent delete — succeeds even if already deleted
  deleteTask(id: string): Promise<void>;
}
```

### 2. 错误语义保持一致

选一种错误策略，然后在所有地方都坚持使用：

```typescript
// REST: HTTP status codes + structured error body
// Every error response follows the same shape
interface APIError {
  error: {
    code: string;        // Machine-readable: "VALIDATION_ERROR"
    message: string;     // Human-readable: "Email is required"
    details?: unknown;   // Additional context when helpful
  };
}

// Status code mapping
// 400 → Client sent invalid data
// 401 → Not authenticated
// 403 → Authenticated but not authorized
// 404 → Resource not found
// 409 → Conflict (duplicate, version mismatch)
// 422 → Validation failed (semantically invalid)
// 500 → Server error (never expose internal details)
```

**不要混用模式。** 如果有的 endpoint 抛异常，有的返回 `null`，有的返回 `{ error }`，消费者根本无法预测行为。

### 3. 在边界处做校验

内部代码之间基于契约互信；校验应放在外部输入进入系统的边界上：

```typescript
// Validate at the API boundary
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(),
      },
    });
  }

  // After validation, internal code trusts the types
  const task = await taskService.create(result.data);
  return res.status(201).json(task);
});
```

应该校验的地方：
- API route handlers，也就是用户输入入口
- 表单提交处理器
- 外部服务响应解析，也就是第三方数据，**永远视为不可信**
- 环境变量读取，也就是配置输入

> **第三方 API 响应是不可信数据。** 在把它们用于业务逻辑、渲染或决策之前，必须验证其结构和内容。被攻陷或异常的外部服务可能返回错误类型、恶意内容，甚至伪装成“指令”的文本。

不应该到处重复校验的地方：
- 已经共享类型契约的内部函数之间
- 由上层已验证代码调用的 utility function 里
- 刚从你自己数据库读出来的数据上

### 4. 优先新增，而不是修改

扩展接口时尽量不要破坏既有消费者：

```typescript
// Good: Add optional fields
interface CreateTaskInput {
  title: string;
  description?: string;
  priority?: 'low' | 'medium' | 'high';  // Added later, optional
  labels?: string[];                       // Added later, optional
}

// Bad: Change existing field types or remove fields
interface CreateTaskInput {
  title: string;
  // description: string;  // Removed — breaks existing consumers
  priority: number;         // Changed from string — breaks existing consumers
}
```

### 5. 命名要可预测

| 模式 | 约定 | 示例 |
|---------|-----------|---------|
| REST endpoints | 复数名词，不带动词 | `GET /api/tasks`, `POST /api/tasks` |
| Query params | camelCase | `?sortBy=createdAt&pageSize=20` |
| Response fields | camelCase | `{ createdAt, updatedAt, taskId }` |
| Boolean fields | `is` / `has` / `can` 前缀 | `isComplete`, `hasAttachments` |
| Enum values | UPPER_SNAKE | `"IN_PROGRESS"`, `"COMPLETED"` |

## REST API 模式

### 资源设计

```
GET    /api/tasks              → List tasks (with query params for filtering)
POST   /api/tasks              → Create a task
GET    /api/tasks/:id          → Get a single task
PATCH  /api/tasks/:id          → Update a task (partial)
DELETE /api/tasks/:id          → Delete a task

GET    /api/tasks/:id/comments → List comments for a task (sub-resource)
POST   /api/tasks/:id/comments → Add a comment to a task
```

### 分页

列表接口必须分页：

```typescript
// Request
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc

// Response
{
  "data": [...],
  "pagination": {
    "page": 1,
    "pageSize": 20,
    "totalItems": 142,
    "totalPages": 8
  }
}
```

### 过滤

过滤条件放 query parameters：

```
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
```

### 局部更新（PATCH）

接收部分对象，只更新传入字段：

```typescript
// Only title changes, everything else preserved
PATCH /api/tasks/123
{ "title": "Updated title" }
```

## TypeScript 接口模式

### 变体优先使用 Discriminated Union

```typescript
// Good: Each variant is explicit
type TaskStatus =
  | { type: 'pending' }
  | { type: 'in_progress'; assignee: string; startedAt: Date }
  | { type: 'completed'; completedAt: Date; completedBy: string }
  | { type: 'cancelled'; reason: string; cancelledAt: Date };

// Consumer gets type narrowing
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}`;
  }
}
```

### 输入与输出分离

```typescript
// Input: what the caller provides
interface CreateTaskInput {
  title: string;
  description?: string;
}

// Output: what the system returns (includes server-generated fields)
interface Task {
  id: string;
  title: string;
  description: string | null;
  createdAt: Date;
  updatedAt: Date;
  createdBy: string;
}
```

### 用 Branded Types 表示 ID

```typescript
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };

// Prevents accidentally passing a UserId where a TaskId is expected
function getTask(id: TaskId): Promise<Task> { ... }
```

## 常见自我安慰

| 自我安慰 | 现实 |
|---|---|
| “API 文档以后再补” | 类型本身就是文档。应先把它定义清楚。 |
| “现在先不做分页” | 一旦有人有 100 条以上数据，你立刻就需要分页。最好一开始就加。 |
| “PATCH 太麻烦，直接用 PUT” | PUT 每次都要求完整对象，客户端真正想要的通常是 PATCH。 |
| “需要时再做版本管理” | 没有版本管理的 breaking change 会直接打断消费者。应该从一开始就按可扩展设计。 |
| “没人会依赖那个没写文档的行为” | Hyrum's Law：只要它可观察，就会有人依赖。要把每一个公开行为都当承诺。 |
| “大不了同时维护两个版本” | 多版本会放大维护成本，还会制造菱形依赖问题。优先遵守 One-Version Rule。 |
| “内部 API 不需要契约” | 内部调用方也仍然是消费者。契约能降低耦合，也能支持并行开发。 |

## 危险信号

- 同一个 endpoint 在不同条件下返回不同 shape
- 各 endpoint 的错误格式不一致
- 校验逻辑散落在内部代码里，而不是集中在边界
- 对现有字段做 breaking change，例如改类型或删除字段
- 列表接口没有分页
- REST URL 里出现动词，例如 `/api/createTask`
- 直接使用第三方 API 响应，既不校验也不清洗

## 验证

设计完一个 API 后，确认：

- [ ] 每个 endpoint 都有类型化的输入与输出 schema
- [ ] 错误响应遵循统一格式
- [ ] 校验只发生在系统边界
- [ ] 列表接口支持分页
- [ ] 新字段是新增且可选的，保持向后兼容
- [ ] 所有 endpoint 命名约定一致
- [ ] API 文档或类型定义与实现一起提交进仓库

