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 |
| クエリパラメータ | キャメルケース | ?sortBy=createdAt&pageSize=20 |
| 応答フィールド | キャメルケース | { 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 '保留中';
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には契約は必要ない」 | 内部コンシューマーもコンシューマーです。契約は結合を防ぎ、並行作業を可能にします。 |
レッドフラグ
- 条件によって異なる形状を返すエンドポイント
- エンドポイント間で一貫性のないエラー形式
- 境界ではなく内部コード全体に散在するバリデーション
- 既存フィールドへの破壊的変更(型変更、削除)
- ページネーションのないリストエンドポイント
- REST URL内の動詞 (
/api/createTask,/api/getUsers) - 検証やサニタイズなしで使用されるサードパーティAPI応答
確認
API設計後:
- すべてのエンドポイントに型付きの入力および出力スキーマがある
- エラーレスポンスが単一の一貫した形式に従っている
- バリデーションがシステム境界でのみ発生する
- リストエンドポイントがページネーションをサポートしている
- 新しいフィールドは追加的かつオプション(後方互換)である
- 命名がすべてのエンドポイントで一貫した規則に従っている
- APIドキュメントまたは型が実装と一緒にコミットされている
Codexコンパニオンファイル
この再構築されたスキルは、関連するローカルリソースをバンドルしているため、Codexは必要に応じて同じスキルディレクトリからそれらをロードできます。