Advanced (更新: 2026/6/2)

Claude CodeでAPIバージョニングを安全に設計する実践ガイド

Claude CodeでAPIバージョニングを壊さず導入する設計・OpenAPI・移行運用の実践ガイド。

Claude CodeでAPIバージョニングを安全に設計する実践ガイド

APIバージョニングは、単にURLへ/v2を足す作業ではありません。既存のモバイルアプリ、取引先のバッチ、社内サービス、Webhook受信側が同じAPIを使っているなら、レスポンスのフィールド名を1つ変えるだけで障害になります。Claude Codeに「新しいAPIを作って」と頼むだけでは、動くコードは速く出ますが、互換性の境界は曖昧になりがちです。

この記事では、Claude CodeをAPIの相棒として使いながら、破壊的変更を防ぐ実務手順をまとめます。URLパス、ヘッダー、メディアタイプの使い分け、OpenAPI契約、後方互換性、DeprecationSunsetヘッダー、変更履歴、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: 2URLを保てる。社内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にはDeprecationSunsetLinkを付けます。

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のレスポンスを変えないことです。customerNamecustomer.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は新しい形で追加します。非推奨はDeprecationSunsetヘッダー、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に明示しないと抜けやすい確認項目です。

#Claude Code #API設計 #APIバージョニング #OpenAPI #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。