APIとインターフェース設計
概要
誤用されにくい、安定した、よく文書化されたインターフェースを設計します。優れたインターフェースは、正しいことを簡単にし、間違ったことを困難にします。これはREST API、GraphQLスキーマ、モジュール境界、コンポーネントプロパティ、そしてあるコードが別のコードと通信するあらゆる表面に適用されます。
使用すべき状況
- 新しいAPIエンドポイントの設計
- モジュール境界またはチーム間の契約の定義
- コンポーネントプロパティインターフェースの作成
- APIの形状に影響を与えるデータベーススキーマの確立
- 既存の公開インターフェースの変更
基本原則
Hyrumの法則
十分な数のAPIユーザーがいる場合、契約で何を約束するかに関係なく、システムのすべての観察可能な動作は誰かによって依存されることになります。
これはつまり、文書化されていない癖、エラーメッセージのテキスト、タイミング、順序を含むすべての公開動作が、ユーザーがそれに依存すると事実上の契約になるということです。設計への影響:
- 公開するものを意図的に選択する。 すべての観察可能な動作は潜在的なコミットメントです。
- 実装の詳細を漏らさない。 ユーザーがそれを観察できれば、それに依存します。
- 設計時に非推奨化を計画する。 ユーザーが依存するものを安全に削除する方法については、
deprecation-and-migrationを参照してください。 - テストだけでは不十分です。 完璧なコントラクトテストがあっても、Hyrumの法則により、「安全な」変更が、文書化されていない動作に依存する実際のユーザーを壊す可能性があります。
One-Version Rule
コンシューマーが同じ依存関係やAPIの複数のバージョンを選択することを強いられるのを避けてください。異なるコンシューマーが同じものの異なるバージョンを必要とする場合に、ダイヤモンド依存関係の問題が発生します。一度に1つのバージョンだけが存在する世界のために設計してください。つまり、フォークするのではなく拡張するということです。
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. 一貫したエラーセマンティクス
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}` // ✗ 正当な$50の課金が2回、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;
}
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つの結果があります。 タイムアウトは、効果が適用されたかどうかについて何も教えてくれません。呼び出しの前に意図を記録することで、呼び出しとレスポンスの間のクラッシュが、後で解決すべき何かの証拠を残し、静かにリトライされる課金を防ぎます。
保持期間を最長のリトライチェーンに基づいて設定し、ディスクコストに基づいて設定しない。 キーは、同じ意図を再配信できるすべてのパスより長持ちする必要があります。これには、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}`;
}
}
入力/出力の分離
// 入力: 呼び出し側が提供するもの
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をバージョン管理する」 | バージョン管理なしの破壊的変更はコンシューマーを壊します。最初から拡張のために設計しましょう。 |
| 「あの文書化されていない動作を使う人は誰もいない」 | Hyrumの法則: 観察可能なら、誰かが依存しています。すべての公開動作をコミットメントとして扱いましょう。 |
| 「2つのバージョンを維持すればいい」 | 複数バージョンはメンテナンスコストを増大させ、ダイヤモンド依存関係問題を生み出します。One-Version Ruleを優先しましょう。 |
| 「内部APIは契約を必要としない」 | 内部コンシューマーもコンシューマーです。契約は結合を防ぎ、並行作業を可能にします。 |
| 「Idempotency-Keyヘッダーを受け入れるだけで十分」 | ヘッダーは契約であり、結果に対してキーを保存することが実装です。受け入れても尊重しないキーは、実際には安全でないのに、クライアントにリトライが安全だと伝えます。 |
| 「私たちのキューは正確に1回の配信を保証する」 | コンシューマークラッシュをまたいでそれを保証するキューはありません。ブローカーのackとあなたの副作用は1つのトランザクションにありません。冪等な処理でat-least-onceのために設計しましょう。 |
| 「重複リクエストはまれです」 | それらは相関しています。リトライはまさに依存関係が低下した瞬間に急増します。これは重複が最も発生しやすく、最もコストがかかる瞬間です。 |
レッドフラグ
- 条件によって異なる形状を返すエンドポイント
- エンドポイント間で一貫性のないエラーフォーマット
- 内部コード全体に散在し、境界にないバリデーション
- 既存のフィールドへの破壊的変更 (型変更、削除)
- ページネーションのないリストエンドポイント
- REST URL内の動詞 (
/api/createTask,/api/getUsers) - バリデーションやサニタイズなしで使用されるサードパーティAPIレスポンス
- 冪等性キーのための
SELECTとそれに続くINSERT— それは競合状態であり、ガードではありません - UUID、タイムスタンプ、または試行ごとに再生成されるその他から導出された冪等性キー
- 異なるリクエストボディで同じキーを受け入れ、最初のレスポンスを黙って返すこと
- リクエストを再配信できる最長パスよりも短いキー保持期間
検証
API設計後:
- すべてのエンドポイントに型付きの入力および出力スキーマがある
- エラーレスポンスが単一の一貫した形式に従っている
- バリデーションがシステム境界でのみ行われる
- リストエンドポイントがページネーションをサポートしている
- 新しいフィールドが追加的かつオプションである (下位互換性がある)
- 命名がすべてのエンドポイントで一貫した規則に従っている
- API文書...