APIとインターフェース設計
概要
誤用されにくく、安定していて、適切に文書化されたインターフェースを設計します。優れたインターフェースは、正しいことを容易にし、誤ったことを困難にします。これはREST API、GraphQLスキーマ、モジュール境界、コンポーネントプロパティ、そしてあるコードが別のコードと通信するあらゆる表面に適用されます。
使用すべきタイミング
- 新しいAPIエンドポイントを設計するとき
- モジュール境界やチーム間の契約を定義するとき
- コンポーネントのプロパティインターフェースを作成するとき
- APIの形状に影響を与えるデータベーススキーマを確立するとき
- 既存の公開インターフェースを変更するとき
核となる原則
ハイラムの法則(Hyrum's Law)
APIのユーザーが十分な数に達すると、契約で何を約束するかに関係なく、システムの観測可能な動作はすべて、誰かによって依存されるようになります。
これは、文書化されていない癖、エラーメッセージのテキスト、タイミング、順序を含むすべての公開動作が、ユーザーがそれに依存し始めると、事実上の契約になることを意味します。設計への影響:
- 公開するものに対して意図的であること。 観測可能な動作はすべて、潜在的なコミットメントです。
- 実装の詳細を漏らさないこと。 ユーザーがそれを観測できれば、彼らはそれに依存するでしょう。
- 設計時に非推奨化を計画すること。 ユーザーが依存しているものを安全に削除する方法については、
deprecation-and-migrationを参照してください。 - テストだけでは不十分です。 完璧な契約テストがあっても、ハイラムの法則は、「安全な」変更が、文書化されていない動作に依存する実際のユーザーを壊す可能性があることを意味します。
ワンバージョンルール(The One-Version Rule)
コンシューマーに、同じ依存関係やAPIの複数のバージョンから選択させることを避けてください。異なるコンシューマーが同じものの異なるバージョンを必要とする場合、ダイヤモンド依存関係の問題が発生します。一度に1つのバージョンだけが存在する世界向けに設計してください。つまり、フォークするのではなく拡張するのです。
1. 契約駆動(Contract First)
実装する前にインターフェースを定義します。契約が仕様であり、実装はそれに従います。
// 最初に契約を定義する
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. 一貫性のあるエラーセマンティクス
1つのエラー戦略を選択し、どこでもそれを使用します:
// 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レスポンスは信頼できないデータです。 ロジック、レンダリング、意思決定で使用する前に、その形状と内容を検証してください。侵害された、または誤動作している外部サービスは、予期しない型、悪意のあるコンテンツ、または命令のようなテキストを返す可能性があります。
検証が属さない場所:
- 型契約を共有する内部関数間
- 既に検証されたコードから呼び出されるユーティリティ関数
- 自分のデータベースから取得したばかりのデータ
4. 変更よりも追加を優先する
既存のコンシューマーを壊さずにインターフェースを拡張します:
// 良い: オプションフィールドを追加
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // 後で追加、オプション
labels?: string[]; // 後で追加、オプション
}
// 悪い: 既存のフィールドの型を変更したり、フィールドを削除したりする
interface CreateTaskInput {
title: string;
// description: string; // 削除 — 既存のコンシューマーを壊す
priority: number; // 文字列から変更 — 既存のコンシューマーを壊す
}
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" |
6. 冪等性キーの尊重
Idempotency-Key を受け入れることは契約です。それを尊重することは実装であり、それが最もコストがかかる部分です — サーバーが受け入れたが不注意に扱うキーは、キーがないよりも悪化します。なぜなら、クライアントはリトライが安全であると信じるようになるからです。
意図からキーを導出し、試行から導出しないでください。 キーは、1つの意図のリトライ間で安定し、異なる意図間で異なる必要があります:
crypto.randomUUID() // ✗ 試行ごとに新しいキー — リトライごとに新しい請求
`${userId}:${amount}` // ✗ 2つの正当な50ドルの請求が1つに統合される
`${orderId}:${Date.now()}` // ✗ タイムスタンプは帽子をかぶったrandomUUID()
req.headers['idempotency-key'] // ✓ クライアントが一度生成し、リトライで再利用
`charge:v1:${orderId}` // ✓ 不変の識別子から導出
キーはクライアントまたは開始イベントから来ます — リトライを実行しているレイヤーから決して来ません。
アトミックに主張する。チェックの後にアクションが続くのは競合状態です:
// ✗ TOCTOU: 2つの同時リトライが両方とも「未確認」を読み取り、両方とも請求する
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ 一意制約に勝者を選ばせる
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
一意制約がメカニズムです。1回の操作で一意性を強制できないストアは、これをバックアップできません。
ペイロードをガードする。 同じキーで異なるボディはクライアントのバグであり、最初の応答を2番目のリクエストに提供するのではなく、大々的に失敗する必要があります:
if (existing.requestHash !== hash(req.body)) {
return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}
実行中の重複が取得するものを決定します。 最初のリクエストがまだ実行中で2番目が到着した場合 — リトライストームの下で一般的なケースです:
| 戦略 | レスポンス | 使用すべき場合 |
|---|---|---|
| 拒否 | 409 Conflict | クライアントは後でリトライ可能; 最もシンプルで安全 |
| 待機 | 結果をブロック、期限付き | 呼び出し元が同期的にそれを必要とする |
| 保留中を返す | 202 + ステータスURL | 長時間実行エフェクト |
最初が「動かなくなったように見える」という理由で、2番目の呼び出し元を通してはいけません。運命が不明な停止した試行は、まさに重複が最もコストがかかる瞬間です。
すべての呼び出しには3つの結果があり、2つではありません:成功、失敗、そして_不明_。 タイムアウトは、エフェクトが適用されたかどうかについて何も教えてくれません。呼び出しを行う前に意図を記録し、呼び出しと応答の間のクラッシュが、後で何かを解決する必要があるという証拠を残すようにします — 黙ってリトライされた請求ではなく。
ディスクコストではなく、最長のリトライチェーンから保持期間を設定します。 キーは、同じ意図を再配信できるすべてのパス(1週間後にリプレイされるデッドレターキューやプロバイダーの異議申し立てウィンドウを含む)よりも長生きする必要があります。7日間のDLQの背後にある24時間のキーTTLは、発生が待たれる重複です。
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インターフェースのパターン
バリアントには判別共用体を使用する
// 良い: 各バリアントが明示的
// 良い例: type 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 '保留中';
case 'in_progress': return 進行中 (${status.assignee});
case 'completed': return 完了 (${status.completedAt});
case 'cancelled': return キャンセル: ${status.reason};
}
}
### 入力/出力の分離
```typescript
// 入力: 呼び出し元が提供するもの
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' };
// TaskIdが期待される場所にUserIdを誤って渡すのを防ぐ
function getTask(id: TaskId): Promise<Task> { ... }
よくある言い訳
| 言い訳 | 現実 |
|---|---|
| 「APIのドキュメントは後で書く」 | 型がドキュメントです。最初に定義しましょう。 |
| 「今はページネーションは必要ない」 | 誰かが100+アイテムを持った瞬間に必要になります。最初から追加しましょう。 |
| 「PATCHは複雑だ、PUTを使おう」 | PUTは毎回完全なオブジェクトを必要とします。PATCHこそがクライアントが実際に望むものです。 |
| 「必要なときにAPIのバージョン管理をする」 | バージョン管理なしの破壊的変更はコンシューマーを壊します。最初から拡張のために設計しましょう。 |
| 「あの文書化されていない動作を使う人はいない」 | ハイラムの法則: 観測可能なら、誰かが依存しています。すべての公開動作をコミットメントとして扱いましょう。 |
| 「2つのバージョンを維持すればいい」 | 複数のバージョンはメンテナンスコストを増やし、ダイヤモンド依存関係の問題を引き起こします。ワンバージョンルールを優先しましょう。 |
| 「内部APIは契約を必要としない」 | 内部のコンシューマーもコンシューマーです。契約は結合を防ぎ、並行作業を可能にします。 |
| 「Idempotency-Keyヘッダーを受け入れるだけで十分だ」 | ヘッダーは契約であり、結果とともにキーを保存することが実装です。受け入れたが尊重しないキーは、実際には安全でないのにリトライが安全であるとクライアントに伝えます。 |
| 「キューが正確に1回の配信を保証する」 | コンシューマーのクラッシュをまたいでそれを保証するキューはありません — ブローカーのackとあなたの副作用は1つのトランザクションにありません。冪等な処理で最低1回の配信向けに設計しましょう。 |
| 「重複リクエストはまれだ」 | それらは相関しています。リトライは、依存関係が低下しているまさにその瞬間に急増します — それは重複が最も発生しやすく、最もコストがかかる瞬間です。 |
危険信号
- 条件に応じて異なる形状を返すエンドポイント
- エンドポイント間で一貫性のないエラーフォーマット
- 境界ではなく内部コード全体に散在するバリデーション
- 既存のフィールドへの破壊的変更(型の変更、削除)
- ページネーションのないリストエンドポイント
- REST URL内の動詞 (
/api/createTask,/api/getUsers) - 検証やサニタイズなしで使用されるサードパーティAPIレスポンス
- 冪等性キーのための
SELECTの後にINSERTが続く — それは競合状態であり、ガードではない - UUID、タイムスタンプ、または試行ごとに再生成されるその他のものから導出された冪等性キー
- 異なるリクエストボディで同じキーが受け入れられ、最初のレスポンスを黙って返す
- リクエストを再配信できる最長のパスよりも短いキー保持期間
検証
APIを設計した後:
- すべてのエンドポイントに型指定された入力および出力スキーマがある
- エラーレスポンスが単一の一貫したフォーマットに従っている
- バリデーションがシステム境界でのみ発生する
- リストエンドポイントがページネーションをサポートしている
- 新しいフィールドは追加的でオプションである(後方互換)
- 命名がすべてのエンドポイントで一貫した規則に従っている
- APIドキュメンテーションが存在し、最新である