Claude CodeでAPIバージョニングを安全に設計する実践ガイド
Claude CodeでAPIバージョニングを壊さず導入する設計・OpenAPI・移行運用の実践ガイド。
APIバージョニングは、単にURLへ/v2を足す作業ではありません。既存のモバイルアプリ、取引先のバッチ、社内サービス、Webhook受信側が同じAPIを使っているなら、レスポンスのフィールド名を1つ変えるだけで障害になります。Claude Codeに「新しいAPIを作って」と頼むだけでは、動くコードは速く出ますが、互換性の境界は曖昧になりがちです。
この記事では、Claude CodeをAPIの相棒として使いながら、破壊的変更を防ぐ実務手順をまとめます。URLパス、ヘッダー、メディアタイプの使い分け、OpenAPI契約、後方互換性、DeprecationとSunsetヘッダー、変更履歴、consumer test、段階的ロールアウト、そしてClaude Codeへのプロンプトまで一気通貫で扱います。
公式仕様として、OpenAPIはOpenAPI Specification、非推奨通知はRFC 9745 Deprecation Header、終了予定の通知はRFC 8594 Sunset Headerを基準にします。Claude Code自体はClaude Code公式ドキュメントの通り、コードベースを読み、ファイルを編集し、コマンドを実行できる開発支援ツールです。だからこそ、作業前に契約と禁止事項を渡すことが重要です。
関連する基礎はClaude Codeで本番API開発、公開前レビューはClaude Codeコードレビュー、リリース管理はChangesetsバージョン管理も合わせて読むと流れがつながります。
最初に守る契約を決める
APIバージョニングの目的は「新旧を並べること」ではなく「利用者が自分のタイミングで移行できること」です。Masaが小さな注文APIで試したとき、最初はfullNameへのリネームをClaude Codeに任せた結果、社内ダッシュボードは通りましたが、取引先のCSV連携が落ちました。原因は、Claude Codeへの依頼に「既存レスポンスを消してはいけない」「v1は何日まで維持する」「consumer testを追加する」が入っていなかったことです。
代表的なユースケースは3つあります。
| ユースケース | 重要な制約 | 向きやすい方式 |
|---|---|---|
| モバイルアプリ向けREST API | 古いアプリが数か月残る | URLパス方式 |
| B2B SaaSのパートナーAPI | 取引先の移行計画が遅い | URLパスまたはヘッダー方式 |
| 社内マイクロサービス | クライアントを一斉更新しやすい | ヘッダーまたはメディアタイプ方式 |
この段階でClaude Codeに渡すべき情報は、現在の利用者、廃止予定日、互換性の定義、監視指標です。コード生成より前に「何を壊したら失敗か」を固定します。
URL・ヘッダー・メディアタイプの比較
バージョンをどこに置くかは、運用とドキュメントに影響します。迷ったら、公開APIではURLパス方式から始めるのが堅実です。人間にもログにも見え、API GatewayやCDNのルーティングが簡単だからです。一方で、リソースのURIにバージョンが混ざるため、長期的には/api/v1/orders/123と/api/v2/orders/123が別物に見えます。
| 方式 | 例 | 強み | 落とし穴 |
|---|---|---|---|
| URLパス | /api/v1/orders | ログ、ドキュメント、ルーティングが分かりやすい | URLが増え、移行後も古いパスが残る |
| カスタムヘッダー | API-Version: 2 | URLを保てる。社内APIで扱いやすい | ブラウザやcurlで見落とされやすい。キャッシュはVaryが必要 |
| メディアタイプ | Accept: application/vnd.acme.orders.v2+json | 表現形式の違いをHTTPの交渉に寄せられる | OpenAPI、SDK生成、サポート対応が複雑になりやすい |
メディアタイプ方式を使うなら、Vary: Acceptを忘れると中間キャッシュがv1とv2を混ぜる可能性があります。ヘッダー方式ならVary: API-Versionを付けます。URL方式でも、レスポンス互換性を変える場合はOpenAPI上でv1とv2を別の契約として扱うべきです。
OpenAPIをソースオブトゥルースにする
OpenAPIは、HTTP APIのパス、メソッド、リクエスト、レスポンス、認証を機械が読める形で書く仕様です。平たく言えば、実装前に固定するAPIの約束です。OpenAPIのopenapiフィールドは仕様バージョンで、info.versionは自分たちのAPI文書のバージョンです。この2つを混同しないように、Claude Codeへ明示します。
以下は、v1を非推奨にしながらv2を追加する最小契約です。ツール互換性を重視してopenapi: 3.1.0にしていますが、最新仕様を確認する場合は公式のOpenAPI文書を見てください。
openapi: 3.1.0
info:
title: Acme Orders API
version: 2.0.0
servers:
- url: https://api.example.com
paths:
/api/v1/orders/{orderId}:
get:
operationId: getOrderV1
summary: Get an order in the legacy v1 shape
deprecated: true
x-deprecated-at: "2026-03-31T00:00:00Z"
x-sunset-at: "2026-12-31T23:59:59Z"
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Legacy order response
headers:
Deprecation:
schema:
type: string
description: RFC 9745 structured date, for example @1774915200
Sunset:
schema:
type: string
description: RFC 8594 HTTP-date when v1 may stop responding
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV1Envelope"
/api/v2/orders/{orderId}:
get:
operationId: getOrderV2
summary: Get an order in the current v2 shape
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Current order response
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV2Envelope"
components:
schemas:
OrderV1Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customerName, totalCents, currency]
properties:
id:
type: string
customerName:
type: string
totalCents:
type: integer
currency:
type: string
OrderV2Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customer, amount, status]
properties:
id:
type: string
customer:
type: object
required: [displayName]
properties:
displayName:
type: string
amount:
type: object
required: [value, currency]
properties:
value:
type: integer
currency:
type: string
status:
type: string
enum: [paid, shipped]
Claude Codeには、このYAMLを読ませてから「この契約から外れる実装をしない」と指示します。レスポンスのキー名、必須項目、ステータスコード、ヘッダーを契約に固定すると、レビューがかなり楽になります。
後方互換のNode実装
次のTypeScriptは、URLパス、API-Versionヘッダー、Acceptメディアタイプの3方式を同じ小さなサーバーで試せるサンプルです。DBもExpressも不要です。v1は古い形を返し続け、v2は新しい形を返します。v1にはDeprecation、Sunset、Linkを付けます。
import { createServer } from "node:http";
import { parse } from "node:url";
type ApiVersion = "v1" | "v2";
type OrderRow = {
id: string;
customerName: string;
totalCents: number;
currency: "JPY" | "USD";
status: "paid" | "shipped";
createdAt: string;
};
const orders = new Map<string, OrderRow>([
[
"o_100",
{
id: "o_100",
customerName: "Masa Tanaka",
totalCents: 129800,
currency: "JPY",
status: "paid",
createdAt: "2026-06-02T09:00:00.000Z",
},
],
]);
function detectVersion(req: { headers: Record<string, string | string[] | undefined> }, pathname: string) {
const pathVersion = pathname.match(/^\/api\/(v[12])\//)?.[1] as ApiVersion | undefined;
if (pathVersion) return { version: pathVersion, source: "path" };
const header = req.headers["api-version"];
if (typeof header === "string") {
const normalized = header.startsWith("v") ? header : `v${header}`;
if (normalized === "v1" || normalized === "v2") {
return { version: normalized, source: "header" };
}
throw new Error(`Unsupported API-Version: ${header}`);
}
const accept = req.headers.accept;
if (typeof accept === "string") {
const mediaMatch = accept.match(/application\/vnd\.acme\.orders\.v([12])\+json/);
if (mediaMatch) {
return { version: `v${mediaMatch[1]}` as ApiVersion, source: "media-type" };
}
}
return { version: "v1" as ApiVersion, source: "default" };
}
function orderIdFrom(pathname: string) {
return pathname.match(/^\/api\/(?:v[12]\/)?orders\/([^/]+)$/)?.[1];
}
function toV1(row: OrderRow) {
return {
data: {
id: row.id,
customerName: row.customerName,
totalCents: row.totalCents,
currency: row.currency,
},
};
}
function toV2(row: OrderRow) {
return {
data: {
id: row.id,
customer: { displayName: row.customerName },
amount: { value: row.totalCents, currency: row.currency },
status: row.status,
createdAt: row.createdAt,
},
};
}
function addDeprecationHeaders(res: import("node:http").ServerResponse) {
const deprecatedAt = Math.floor(Date.parse("2026-03-31T00:00:00Z") / 1000);
res.setHeader("Deprecation", `@${deprecatedAt}`);
res.setHeader("Sunset", new Date("2026-12-31T23:59:59Z").toUTCString());
res.setHeader(
"Link",
[
'<https://docs.example.com/api/deprecations/v1-to-v2>; rel="deprecation"; type="text/html"',
'<https://docs.example.com/api/sunset-policy>; rel="sunset"; type="text/html"',
].join(", "),
);
}
function sendJson(res: import("node:http").ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body, null, 2));
}
const server = createServer((req, res) => {
const pathname = parse(req.url ?? "/").pathname ?? "/";
const orderId = orderIdFrom(pathname);
if (!orderId) {
return sendJson(res, 404, { error: "not_found", message: "Route not found" });
}
let detected: ReturnType<typeof detectVersion>;
try {
detected = detectVersion(req, pathname);
} catch (error) {
return sendJson(res, 400, {
error: "unsupported_version",
message: error instanceof Error ? error.message : "Unsupported API version",
supportedVersions: ["v1", "v2"],
});
}
const row = orders.get(orderId);
if (!row) {
return sendJson(res, 404, { error: "order_not_found", orderId });
}
res.setHeader("Vary", "Accept, API-Version");
res.setHeader("X-API-Version", detected.version);
res.setHeader("X-API-Version-Source", detected.source);
if (detected.version === "v1") {
addDeprecationHeaders(res);
return sendJson(res, 200, toV1(row));
}
return sendJson(res, 200, toV2(row));
});
const port = Number(process.env.PORT ?? 18080);
server.listen(port, () => {
console.log(`API versioning demo: http://localhost:${port}`);
});
動作確認は次の通りです。
npm init -y
npm install -D tsx typescript @types/node
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
curl -i http://localhost:18080/api/v1/orders/o_100
curl -i -H "API-Version: 2" http://localhost:18080/api/orders/o_100
curl -i -H "Accept: application/vnd.acme.orders.v2+json" http://localhost:18080/api/orders/o_100
kill "$SERVER_PID"
ここで重要なのは、v2の実装に寄せるためにv1のレスポンスを変えないことです。customerNameをcustomer.displayNameに置き換えたくなっても、v1の契約では残します。削除や型変更は移行完了後です。
非推奨ヘッダーと変更ポリシー
以前のサンプルではDeprecation: trueのような表現を見かけましたが、RFC 9745ではDeprecationは構造化ヘッダーのDate値です。たとえばUnix秒に@を付けた@1774915200のような形式です。SunsetはRFC 8594のHTTP-dateで、リソースが応答しなくなる可能性がある日時を示します。
ヘッダーだけでは人間が移行できないため、バージョンポリシーもリポジトリに置きます。
currentApiVersion: v2
minimumSupportWindowMonths: 12
breakingChangeRequires:
- new-major-version
- migration-guide
- consumer-test
- owner-approval
deprecatedVersions:
- version: v1
deprecatedAt: "2026-03-31T00:00:00Z"
sunsetAt: "2026-12-31T23:59:59Z"
replacement: "/api/v2/orders/{orderId}"
migrationGuide: "https://docs.example.com/api/deprecations/v1-to-v2"
CHANGELOGには、追加、変更、非推奨、削除予定を分けて書きます。「v1を廃止します」だけでは不十分です。誰が、いつまでに、どのフィールドを、どの代替へ移すのかを書きます。
Consumer testで破壊的変更を止める
consumer testは、API提供者ではなく利用者の期待を表すテストです。v1利用者にとっては「v2がきれいか」ではなく「v1のフィールドが消えていないか」が重要です。次のテストはサーバーを起動した状態で実行できます。
import assert from "node:assert/strict";
import test from "node:test";
const baseUrl = process.env.API_BASE_URL ?? "http://localhost:18080";
test("v1 keeps the legacy response shape", async () => {
const res = await fetch(`${baseUrl}/api/v1/orders/o_100`);
assert.equal(res.status, 200);
assert.match(res.headers.get("deprecation") ?? "", /^@\d+$/);
assert.match(res.headers.get("sunset") ?? "", /GMT$/);
const body = await res.json();
assert.equal(body.data.customerName, "Masa Tanaka");
assert.equal(body.data.customer, undefined);
});
test("v2 returns the current response shape", async () => {
const res = await fetch(`${baseUrl}/api/orders/o_100`, {
headers: { "API-Version": "2" },
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.data.customer.displayName, "Masa Tanaka");
assert.equal(body.data.amount.currency, "JPY");
assert.equal(body.data.customerName, undefined);
});
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
API_BASE_URL=http://localhost:18080 node --test version-contract.test.mjs
kill "$SERVER_PID"
このテストをCIに入れてからClaude Codeに実装を依頼すると、「リファクタリングのつもりでレスポンスを変える」事故をかなり減らせます。OpenAPIのlintも合わせるなら、npx @redocly/cli lint openapi.yamlのようなコマンドを確認手順に加えます。
ロールアウトとフォールバックを用意する
APIバージョニングの失敗例はだいたい決まっています。1つ目は、DBスキーマ変更とAPIレスポンス変更を同じ日に実施して戻せなくなること。2つ目は、v1利用者の実数を見ないままSunset日を決めること。3つ目は、SDKだけ更新して生のHTTP利用者を忘れること。4つ目は、ドキュメントでは非推奨にしたのに、ログとアラートで検知していないことです。
ロールアウトは、v2追加、v1非推奨ヘッダー追加、利用率計測、移行先SDK公開、取引先通知、Sunset、削除の順に分けます。フォールバックは「v2を止めてもv1が動く」「新しいレスポンス項目を無視しても旧クライアントが動く」「DBマイグレーションを戻せるか、少なくとも読み取り互換を保つ」の3点です。
mkdir -p tmp/version-snapshots
BASE_URL=${BASE_URL:-http://localhost:18080}
for order_id in o_100 missing; do
curl -sS -D "tmp/version-snapshots/${order_id}.v1.headers" \
"$BASE_URL/api/v1/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v1.json" || true
curl -sS -D "tmp/version-snapshots/${order_id}.v2.headers" \
-H "API-Version: 2" \
"$BASE_URL/api/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v2.json" || true
done
このスナップショットをPRに添付すると、レビュー担当者は「どのレスポンスが変わったか」を目で確認できます。Claude Codeにもこの出力を読ませ、差分の説明を作らせるとレビューが速くなります。
Claude Codeに渡すプロンプト
Claude Codeへは、タスクだけでなく禁止事項と検証コマンドを渡します。APIバージョニングでは、次のようなプロンプトが効きます。
既存APIにv2を追加してください。OpenAPIファイルをソースオブトゥルースとして扱い、v1のレスポンス形状・ステータスコード・ヘッダーを変更しないでください。
作業前に次を列挙してください。
- 破壊的変更になり得る箇所
- v1で維持するフィールド
- v2で追加または変更するフィールド
- 追加するconsumer test
実装後に次を実行してください。
- npm test
- npx @redocly/cli lint openapi.yaml
- curlでv1とv2のレスポンスを比較
最終回答には、互換性リスク、移行ガイドに書くべき内容、ロールバック手順を含めてください。
もう1つ、レビュー専用プロンプトも用意しておくと便利です。
この差分をAPI互換性レビューとして見てください。
観点:
- v1の必須レスポンスフィールドが削除・リネーム・型変更されていないか
- エラー形式、HTTPステータス、ページネーション、ソート順が変わっていないか
- Deprecation、Sunset、Link、Varyヘッダーが仕様通りか
- OpenAPI、実装、テスト、CHANGELOGの内容が一致しているか
- ロールバック時にv1利用者へ影響が出ないか
問題があれば、ファイル名と具体的な修正案を出してください。
Claude Codeは速いぶん、曖昧な依頼では「きれいな現在形」へ寄せがちです。APIでは、それが壊れる変更になります。プロンプトに契約、禁止事項、検証、移行文書を入れるのが実務上のコツです。
まとめ
Claude CodeでAPIバージョニングを扱うなら、最初にOpenAPIで契約を固定し、URL・ヘッダー・メディアタイプのどれを採用するかを運用込みで決めます。v1は互換性を維持し、v2は新しい形で追加します。非推奨はDeprecationとSunsetヘッダー、CHANGELOG、移行ガイド、consumer testで支えます。
チームでClaude CodeをAPI開発に入れるなら、Claude Code研修・導入相談で、実際のAPI契約、CI、レビューゲート、移行ガイドまでまとめて設計できます。まず手元で型を試したい場合は無料チートシートから、プロンプトと確認コマンドを固定するのが現実的です。
この記事で紹介した内容を実際に試した結果、v1とv2を同じDB行から変換する小さなNodeサーバーでも、consumer testを入れるだけでリネーム事故を検出できました。特にDeprecation: trueではなくRFC 9745のDate形式に直す点、Varyを付ける点、OpenAPIと実装を同時にレビューする点は、Claude Codeに明示しないと抜けやすい確認項目です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code Permission Receipt Pattern: 許可、証拠、ロールバックを残す運用
Claude Codeの権限運用を安全にする permission receipt。許可範囲、承認待ち、検証コマンド、CTA導線を記録します。
Claude CodeとCodex、結局どっち?事故らない“併用”の現実解
OpenAIのCodexとClaude Code、どっちが得意でどっちに任せる?両方を安全に併用する作業分担と権限・検証のワークフローを、僕の失敗談つきで解説します。
Claude Codeサブエージェント実装ガイド: 記事・コード作業を安全に並列委譲する方法
Claude Codeサブエージェントで記事・コード作業を安全に並列化する実装ガイド。委譲基準、プロンプト、失敗例を解説。