ストーリー
高橋アーキテクトが力説した。
後方互換性の原則
安全な変更(後方互換)
// 1. フィールドの追加(既存クライアントは無視する)
// Before
{ "id": "123", "name": "田中" }
// After(avatarUrl を追加)
{ "id": "123", "name": "田中", "avatarUrl": "https://..." }
// → 既存クライアントは avatarUrl を知らなくても動作する
// 2. オプショナルパラメータの追加
// Before: GET /api/v1/users?page=1
// After: GET /api/v1/users?page=1&role=admin (role は任意)
// → role を指定しなくても動作する
// 3. 新しいエンドポイントの追加
// POST /api/v1/users/bulk (一括作成のエンドポイント追加)
// → 既存のエンドポイントに影響なし
// 4. 新しいHTTPステータスコードの追加
// 429 Too Many Requests を返すようにした
// → クライアントは未知のステータスコードでも4xxとして処理可能
危険な変更(破壊的変更)
// 1. フィールドの削除
// Before: { "id": "123", "name": "田中", "age": 25 }
// After: { "id": "123", "name": "田中" }
// → "age" を参照しているクライアントが壊れる
// 2. フィールド名の変更
// Before: { "name": "田中" }
// After: { "fullName": "田中" }
// → "name" を参照しているクライアントが壊れる
// 3. 型の変更
// Before: { "price": 1500 } // number
// After: { "price": "1500" } // string
// → 型が変わるとパースに失敗する
// 4. 必須パラメータの追加
// Before: POST /api/v1/users { "name": "田中" }
// After: POST /api/v1/users { "name": "田中", "email": "..." } // emailが必須に
// → email を送らないクライアントがエラーになる
// 5. レスポンス構造の変更
// Before: { "users": [...] }
// After: { "data": { "users": [...] } }
// → ネスト構造が変わるとパースに失敗する
互換性維持テクニック
テクニック1: フィールドの追加で対応
// 名前を姓・名に分離したい場合
// 悪い例: フィールド名を変更
{ "name": "田中太郎" } // v1
{ "firstName": "太郎", "lastName": "田中" } // v2(破壊的変更)
// 良い例: 新フィールドを追加、旧フィールドも残す
{
"name": "田中太郎", // 既存(維持)
"firstName": "太郎", // 新規追加
"lastName": "田中" // 新規追加
}
// → 既存クライアントは "name" を使い続けられる
// → 新しいクライアントは firstName/lastName を使う
テクニック2: デフォルト値の活用
// 新しいパラメータを追加する場合はデフォルト値を設定
// Before
GET /api/v1/users?page=1&perPage=20
// After(sort パラメータ追加)
GET /api/v1/users?page=1&perPage=20&sort=-createdAt
// sort のデフォルト値: "-createdAt"
// → sort を指定しないクライアントでも従来通り動作
// TypeScript の型定義
interface UserListQuery {
page?: number; // デフォルト: 1
perPage?: number; // デフォルト: 20
sort?: string; // デフォルト: "-createdAt"(新規追加)
role?: string; // デフォルト: なし(新規追加)
}
テクニック3: Tolerant Reader パターン
// クライアント側の設計: 未知のフィールドを無視する
// 悪い例: レスポンスの全フィールドに厳密な型チェック
interface User {
id: string;
name: string;
email: string;
// → サーバーが新フィールドを追加するとパースエラーの可能性
}
// 良い例: 必要なフィールドだけ参照し、未知のフィールドは無視
function parseUser(data: Record<string, unknown>): UserView {
return {
id: data.id as string,
name: data.name as string,
// email は使わないなら参照しない
// 未知のフィールドがあっても問題ない
};
}
// Zodを使った実装
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}).passthrough(); // 未知のフィールドを許容
// → サーバーが新しいフィールドを追加してもパースが成功する
テクニック4: Expand/Contract パターン
// 3段階で安全にフィールドを変更する
// Phase 1: Expand(拡張)
// 新しいフィールドを追加、古いフィールドも残す
{
"name": "田中太郎", // 旧フィールド
"firstName": "太郎", // 新フィールド
"lastName": "田中" // 新フィールド
}
// Phase 2: Migrate(移行)
// クライアントに新フィールドへの移行を促す
// ログで旧フィールドの使用状況を監視
// Phase 3: Contract(縮小)
// 十分な移行期間後、旧フィールドを削除
{
"firstName": "太郎",
"lastName": "田中"
}
互換性テストの自動化
// コントラクトテスト: APIレスポンスの構造を検証
import { describe, it, expect } from 'vitest';
describe('User API Backward Compatibility', () => {
it('v1のレスポンス形式が維持されている', async () => {
const response = await fetch('/api/v1/users/123');
const body = await response.json();
// 必須フィールドが存在することを確認
expect(body.data).toHaveProperty('id');
expect(body.data).toHaveProperty('name');
expect(body.data).toHaveProperty('email');
// 型のチェック
expect(typeof body.data.id).toBe('string');
expect(typeof body.data.name).toBe('string');
// 新しいフィールドが追加されていても OK
// (テストは既存フィールドの存在のみを確認)
});
});
まとめ
| テクニック | 説明 | 効果 |
|---|---|---|
| フィールド追加 | 旧フィールドを残しつつ新フィールドを追加 | バージョンアップ不要 |
| デフォルト値 | 新パラメータにデフォルト値を設定 | 既存クライアントへの影響なし |
| Tolerant Reader | クライアントが未知のフィールドを無視 | 柔軟な拡張が可能 |
| Expand/Contract | 3段階で安全にフィールドを移行 | 計画的な変更 |
チェックリスト
- 安全な変更と破壊的変更の区別ができる
- フィールド追加による互換性維持の方法を理解した
- Tolerant Reader パターンの概念を把握した
- Expand/Contract パターンの3段階を理解した
次のステップへ
後方互換性の維持テクニックを学びました。
次のセクションでは、API廃止とマイグレーション戦略を学びます。 古いバージョンをどう廃止し、クライアントをどう移行させるかの戦略です。
推定読了時間: 30分