api-and-interface-design について
REST API、モジュール境界、コンポーネントのプロパティなどを設計する開発者やコーディングエージェント向けのガイドです。契約の先行定義、一貫したエラー処理、境界での入力検証を重視します。
主な機能
- 01インターフェース契約を先に定義
- 02一貫したエラー処理
- 03システム境界で入力を検証
- 04互換性を保ってインターフェースを拡張
- 05命名とページネーションを統一
- 06冪等リクエストを安全に処理
活用シーン
- 01新しいAPIエンドポイントの設計
- 02モジュールやチーム間の契約定義
- 03既存の公開インターフェースの変更
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ addyosmani/ agent-skills --skill api-and-interface-designインストール後、Claude Code で/api-and-interface-designを入力して読み込まれたか確認します。
詳細
API とインターフェースの設計
概要
誤用されにくく、安定した十分なドキュメントを備えたインターフェースを設計します。優れたインターフェースは、正しい使い方を簡単にし、誤った使い方を難しくします。これは REST API、GraphQL スキーマ、モジュールの境界、コンポーネントの props、そしてコードの一部が別の部分とやり取りするあらゆる接点に当てはまります。
使用する場面
- 新しい API エンドポイントを設計するとき
- モジュールの境界やチーム間の契約を定義するとき
- コンポーネントの props インターフェースを作成するとき
- API の形状に影響するデータベーススキーマを確立するとき
- 既存の公開インターフェースを変更するとき
基本原則
ハイラムの法則
API の利用者が十分な数に達すると、契約で何を約束しているかにかかわらず、システムの観察可能な動作はすべて、誰かに依存されるようになります。
つまり、ドキュメントに記載されていない癖、エラーメッセージの文面、タイミング、順序なども含め、公開されているあらゆる動作は、利用者が依存した時点で事実上の契約になります。設計上の意味は次のとおりです。
- 公開する内容を意図的に決めてください。 観察可能な動作はすべて、将来の保証事項になり得ます。
- 実装の詳細を漏らさないでください。 利用者から観察できることは、いずれ依存されます。
- 設計時点で非推奨化を計画してください。 利用者が依存しているものを安全に削除する方法については、
deprecation-and-migrationを参照してください。 - テストだけでは不十分です。 契約テストが完璧でも、ハイラムの法則により、「安全な」変更がドキュメントにない動作に依存する実際の利用者を破綻させることがあります。
単一バージョンの原則
利用者に、同じ依存関係や API の複数のバージョンから選ばせることは避けてください。異なる利用者が同じものの異なるバージョンを必要とすると、ダイヤモンド依存関係の問題が生じます。同時に存在するバージョンは常に 1 つだけだと考えて設計し、分岐ではなく拡張してください。
1. 契約を先に定める
実装の前にインターフェースを定義してください。契約が仕様であり、実装はそれに従います。
// 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. 一貫したエラーの扱い
エラーの扱い方を 1 つ選び、どこでも統一してください。
// 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)
パターンを混在させないでください。 一部のエンドポイントは例外をスローし、別のエンドポイントは null を返し、さらに別のエンドポイントは { error } を返すようでは、利用者は動作を予測できません。
3. 境界で検証する
内部コードは信頼し、外部入力が入ってくるシステムの境界で検証してください。
// 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 ルートハンドラー(ユーザー入力)
- フォーム送信ハンドラー(ユーザー入力)
- 外部サービスのレスポンス解析(サードパーティのデータ — 常に信頼できないものとして扱ってください)
- 環境変数の読み込み(設定)
サードパーティ API のレスポンスは信頼できないデータです。 ロジック、レンダリング、判断に使う前に、形状と内容を検証してください。侵害された外部サービスや不正な動作をする外部サービスは、予期しない型、悪意のあるコンテンツ、または指示のようなテキストを返す可能性があります。
検証しない場所:
- 型の契約を共有する内部関数間
- すでに検証済みのコードから呼び出されるユーティリティ関数内
- 自分たちのデータベースから取得したばかりのデータ
4. 変更より追加を優先する
既存の利用者を破壊することなく、インターフェースを拡張してください。
// 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 エンドポイント | 複数形の名詞。動詞は使わない | 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() // ✗ new key per attempt — every retry is a new charge
`${userId}:${amount}` // ✗ two legitimate $50 charges collapse into one
`${orderId}:${Date.now()}` // ✗ a timestamp is randomUUID() wearing a hat
req.headers['idempotency-key'] // ✓ client generates once, reuses on retry
`charge:v1:${orderId}` // ✓ derived from an immutable identifier
キーの生成元はクライアントまたは開始イベントです。再試行を行うレイヤーが生成してはいけません。
アトミックに確保します。確認してから実行する方法では競合が発生します。
// ✗ TOCTOU: two concurrent retries both read "not seen", both charge
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ let the unique constraint pick the winner
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つ目のリクエストが届いたとき、1つ目のリクエストはまだ実行中です。再試行が集中する状況では、これはよくあるケースです。
| 戦略 | 応答 | 使用する状況 |
|---|---|---|
| 拒否 | 409 Conflict | クライアントが後で再試行できる場合。最もシンプルで安全です |
| 待機 | 結果が出るまで、上限付きでブロック | 呼び出し元が同期的に結果を必要とする場合 |
| 処理中を返す | 202 + ステータス URL | 時間のかかる処理の場合 |
1つ目の呼び出しが「停止しているように見える」という理由で、2つ目の呼び出しを通してはいけません。処理が停止し、結果が不明な試行こそ、重複によるコストが最も大きくなる状況です。
すべての呼び出しには、成功と失敗の2つではなく、3つの結果があります。成功、失敗、そして 不明 です。 タイムアウトしても、処理が適用されたかどうかはわかりません。外部サービスを呼び出す前に意図を記録してください。そうすれば、呼び出しとレスポンスの間にクラッシュが起きても、後で解決すべき処理があるという証拠が残り、課金処理が気付かれないまま再試行される事態を防げます。
ディスクコストではなく、最長の再試行チェーンを基準に保持期間を設定します。 キーは、同じ意図が再配信される可能性のあるすべての経路を超えて保持しなければなりません。これには、1週間後に再実行されるデッドレターキューや、プロバイダーの異議申し立て期間も含まれます。7日間の DLQ の背後でキーの TTL が24時間なら、重複が発生するのを待っているようなものです。
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
ページネーション
リストエンドポイントにはページネーションを使用します。
// Request
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Response
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
フィルタリング
フィルターにはクエリパラメーターを使用します。
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
部分更新(PATCH)
部分オブジェクトを受け付け、指定された項目のみを更新します。
// Only title changes, everything else preserved
PATCH /api/tasks/123
{ "title": "Updated title" }
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}`;
}
}
入力と出力を分離する
// 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;
}
ID にはブランド型を使用する
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 です。 |
| 「必要になったら API にバージョンを付けます」 | バージョン管理なしの破壊的変更は、利用側に影響を与えます。最初から拡張を考慮して設計してください。 |
| 「ドキュメント化されていない挙動を使う人はいません」 | ハイラムの法則:観測可能なものには、それに依存する人がいます。すべての公開された挙動を約束として扱ってください。 |
| 「2 つのバージョンを維持すればよいだけです」 | 複数のバージョンは保守コストを増大させ、ダイヤモンド依存関係の問題を引き起こします。One-Version Rule を優先してください。 |
| 「内部 API にコントラクトは不要です」 | 内部の利用者も利用者です。コントラクトは結合を防ぎ、並行作業を可能にします。 |
| 「Idempotency-Key ヘッダーを受け付ければ十分です」 | ヘッダーはコントラクトであり、キーを結果に紐付けて保存するのが実装です。受け付けるだけで適用しないキーは、実際には安全でない再試行を安全だとクライアントに誤認させます。 |
| 「キューが厳密に 1 回だけの配信を保証してくれます」 | コンシューマーのクラッシュをまたいでそれを保証できるキューはありません。ブローカーの ACK と副作用は 1 つのトランザクションではないからです。冪等な処理による少なくとも 1 回の配信を前提に設計してください。 |
| 「重複リクエストはまれです」 | 重複は相関しています。依存先の劣化時に再試行が急増します。そのタイミングこそ、重複が最も起こりやすく、最もコストがかかるときです。 |
注意すべき危険信号
- 条件によって異なる形式を返すエンドポイント
- エンドポイント間で一貫性のないエラー形式
- 境界ではなく内部コード全体に散在する検証処理
- 既存フィールドへの破壊的変更(型の変更、削除)
- ページネーションのないリストエンドポイント
- REST URL に動詞が含まれている(
/api/createTask、/api/getUsers) - サードパーティ API のレスポンスを検証やサニタイズなしで使用している
- 冪等性キーを
SELECTしてからINSERTする処理 — これは競合状態であり、ガードではありません - UUID、タイムスタンプ、その他の試行ごとに再生成される値から導出された冪等性キー
- 同じキーを異なるリクエスト本文で受け付け、最初のレスポンスを黙って返す処理
- リクエストを再配信し得る最長の経路より短いキー保持期間
検証
API の設計後に、次の点を確認してください。
- すべてのエンドポイントに型付きの入力スキーマと出力スキーマがある
- エラーレスポンスが単一の一貫した形式に従っている
- 検証がシステム境界でのみ行われている
- リストエンドポイントがページネーションに対応している
- 新しいフィールドが追加的かつ任意である(後方互換性がある)
- すべてのエンドポイントで一貫した命名規則に従っている
- API ドキュメントまたは型定義が実装とともにコミットされている
- 状態を変更するエンドポイントが冪等性キーを適用するか、再試行が安全でないことを明記している
- 一意制約で保護された 1 回のアトミックな操作でキーが確保される
- ペイロードの異なるキーが再利用された場合、誤ったレスポンスを再生するのではなく、明確に失敗する
- 処理中の重複に対するレスポンスが、結果任せではなく意図的に選択されている(409、待機、または 202)
- デッドレターの再生を含め、キーの保持期間が最長の再試行経路を上回っている