JSON 契約のバージョニング:互換性、非推奨化、データ移行
要点:フィールドの追加は自動的に安全ではありません。互換性の境界、バージョンフィールド、非推奨の期間、二重書き込みの移行、契約テスト、カナリア指標を扱います。

要点:バージョンの進化は expand、backfill、contract の順に進め、バージョンを文書内に永続化しましょう。出荷前に消費側が未知のフィールドと enum 値を許容することを確認し、非推奨化は freeze、announce、observe、remove の順で進めます。
バージョニングの難しい部分はコードの変更ではなく、誰がまだ古いフィールドを読んでいるかを知り得ないことです。互換性は決して一方向の約束ではなく、発行者と未知の消費者の継続的なすり合わせです。
1. 互換性の境界:追加は自動的に安全ではない
教科書は新しい任意フィールドを「後方互換」と呼びますが、それは消費者が未知のフィールドを許容するときだけ成り立ちます。フィールドの追加は少なくとも三つの状況でクライアントを壊します。
- 消費者が
additionalProperties: falseを設定したスキーマで検証している。 - 消費者がオブジェクトを固定の順序で歩くか、キーではなく位置で値を読む。
- 新しいフィールドが古い名前を再利用しながら意味を変える。たとえば
statusを「注文ステータス」から「フルフィルメントステータス」に広げる。
安全な追加には三つの前提が必要です。フィールドが任意で、意味が独立し、消費者が未知のキーを無視する。バージョン番号を論じる前に三つすべてを確認してください。
{
"schemaVersion": 2,
"orderId": "ord-8842",
"status": "paid",
"refundDeadline": "2026-11-30T00:00:00+08:00",
"channel": "wechat"
}
ここで refundDeadline と channel は v2 の任意の追加で、orderId と status は元の意味論を保ちます。schemaVersion は HTTP ヘッダーだけに住むのではなく、文書とともに永続化されます。
2. 出荷できない破壊的変更
以下の変更は、新しいバージョンか二重書き込みの移行を要するレッドラインとして扱ってください。ドキュメントの更新では救われません。
| 変更 | 互換性を壊す理由 | 許容できる代替 |
|---|---|---|
| フィールドを削除 | 消費者は即座に undefined を読む |
非推奨化し、観察し、期間後に削除 |
| フィールドの型を変更 | "1" が 1 になる。強い型付けのクライアントは即座に失敗し、緩い型付けでも検証でよく失敗する |
新しいフィールド名を足し、移行期間中は二重書き込み |
| 必須フィールドを追加 | 古い呼び出し元は送らないので検証が常に失敗 | 既定値を提供するか、新バージョンでのみ必須にする |
| enum の値集合を変更 | 消費者の switch にフォールバック分岐がない |
まずクライアントに未知値を受け入れさせ、それから enum を広げる |
| フィールドの意味を変更 | 型は有効なまま振る舞いが変わる。デバッグが最も難しい | 新しいフィールドとして扱い、古い意味を凍結 |
| ネストの深さを変更 | すべてのアクセスパスを変えなければならない | 古い形を保ち、平坦なフィールドか expand パラメータを足す |
意味の変更が最も危険な行です。スキーマ検証は緑のまま、監視はエラーを報告せず、業務指標だけがずれます。意味の変更はすべて新しいフィールドとして出荷すべきです。
3. バージョンはどこに住むか:文書、URL、メディアタイプ
三つの一般的な配置は異なる目的に役立ちます。
| 場所 | 例 | 向く場面 | 制限 |
|---|---|---|---|
| 文書フィールド | "schemaVersion": 2 |
データベース、メッセージキュー、オフラインバックアップ | 転送層がそれでルーティングできない |
| URL パス | /v2/orders/8842 |
公開 API | リソースアドレスをバージョンに結合し、移行を高コストにする |
| メディアタイプ | application/vnd.acme.order.v2+json |
コンテンツネゴシエーションが必要な内部サービス | 呼び出し元が Accept を正しく設定しなければならない |
実践的な分担:回線ではメディアタイプのネゴシエーションを使い、文書には常にバージョンフィールドを永続化する。キューの古いメッセージは URL を変えてもアップグレードされず、バックアップファイルも同様です。文書内の schemaVersion だけが、再生、バックフィル、監査に土台を与えます。
GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json
URL でバージョン管理するなら、少なくとも非推奨期間全体を通して古いパスが古い形を返すようにし、静かに新しい実装へ向けないでください。より広い API 契約の決定はJSON API データ契約設計の要点を参照してください。
4. 非推奨の期間を開く
非推奨は一度の告知ではなく、開始、観察期間、終了を持つ窓です。実行可能なリズム:
- 凍結:古いフィールドへの書き込みを止めるが、読み取りは続ける。
- 告知:削除日をドキュメント、変更履歴、レスポンスヘッダーに同時に記す。
- 観察:呼び出し元ごとに古いフィールドの読み取りを数え、連続二期間ゼロになるまで続ける。
- 削除:期間が閉じ、読み取りがゼロで、回帰テストが通ってから、フィールドと互換コードを削除する。
Sunset レスポンスヘッダー(RFC 8594)はクライアントに機械可読な削除日を与えます。Deprecation はまだ IETF ドラフトです。使えますが、すべてのクライアントが理解すると仮定しないでください。移行ガイドを指す Link ヘッダーと組み合わせます。
Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"
期間の長さはチームの慣習です。一般的な下限は一リリースサイクルか 90 日の長い方です。公開 API はより長く、内部サービスはより短くできますが、すべての呼び出し元がアップグレードするのに必要な時間を下回ってはなりません。
5. 非推奨化の前に消費者を見つける
非推奨化の前に、「誰がまだこれを読んでいるか」に答えられなければなりません。さもないと期間は自己欺瞞です。証拠を信頼性順に並べます。
- ゲートウェイログ:古いフィールドへのアクセスを
appId、User-Agent、クライアントバージョンで集計。 - 分散トレース:シリアライズ層で古いフィールドにタグを付け、トレースが呼び出しサービス名を直接示すようにする。
- キュー消費者の登録簿:コンシューマグループの一覧はコード検索より信頼できます。
- リポジトリ検索:
rgは自分のコードしかカバーせず、外部の呼び出し元には依然ログが必要です。
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats
検索ヒットは「このリポジトリの誰かが読んでいる」ことを証明し、「他に誰も読んでいない」ことは証明しません。ゲートウェイログを日ごとにエクスポートし、古いフィールドの読み取りがゼロに保たれてから削除してください。
6. データ移行:expand、backfill、contract
Martin Fowler の Parallel Change は三つの段階を述べます。expand(新旧フィールドが共存し、両バージョンを読める)、backfill(履歴レコードを新しい形に移す)、contract(古いフィールドと互換分岐を削除する)。順序を逆にするとロールバック経路がなくなります。
// 移行の骨格:主キーでカーソル、冪等性のために条件付き書き込み
async function migrateBatch(collection, cursor, batchSize = 500) {
const records = await collection
.find({ _id: { $gt: cursor } })
.limit(batchSize)
.toArray();
if (records.length === 0) {
return { nextCursor: cursor, done: true };
}
const operations = records
.filter((record) => record.schemaVersion < 2)
.map((record) => ({
updateOne: {
filter: { _id: record._id, schemaVersion: record.schemaVersion },
update: {
$set: {
schemaVersion: 2,
status: record.legacyStatus ?? record.status,
refundDeadline: record.refundDeadline ?? null,
},
},
},
}));
if (operations.length > 0) {
await collection.bulkWrite(operations);
}
return { nextCursor: records.at(-1)._id, done: false };
}
見落としやすい詳細:
- 冪等性:
filterに古いバージョン番号を含め、再実行が移行済みレコードを決して書き換えないようにする。 - バッチとチェックポイント:
_idカーソルで進み、チェックポイントを保存し、失敗後にそこから再開する。 - リハーサル:本番スナップショットに対してスクリプトを実行し、前後のレコード数とチェックサムを比較する。
- ロールバック:新しいフィールドが検証を通るまで古いフィールドを保ち、読み取り経路をフラグで切り替え、
schemaVersionを編集するだけでロールバックしない。 - 検証:移行後に業務制約(金額が非負、正当な状態遷移)を実行し、JSON パースチェックだけで済ませない。
設定データも同じ手順に従います。ファイルの整理方法は設定、フィクスチャ、ローカルデータの JSON 管理を参照してください。
7. 契約テスト:互換性を CI に入れる
互換性はコードレビューの記憶に依存できず、テストが必要です。二層で十分です。
- 形のテスト:レスポンスを JSON Schema で検証するが、消費者が使うスキーマには
additionalProperties: falseを設定しない。 - 消費者サブセットのテスト:古い消費者が本当に依存するフィールドをサブスキーマに抽出し、新しいレスポンスがなおそれを満たすことを表明する。
// 契約テスト:新しいレスポンスは古い消費者のサブセットをなお満たさなければならない
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';
const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);
test('v2 のレスポンスは v1 消費者のサブセットをなお満たす', () => {
const response = buildOrderResponse({ schemaVersion: 2 });
const { schemaVersion, ...legacyFields } = response;
const isValid = validateLegacySubset(legacyFields);
assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});
サブスキーマは古い消費者が読むフィールドと型だけを宣言し、新しいフィールドはすべて通します。追加に対する誤報なしで、削除されたフィールドと型変更を捕まえます。ケースデータはフィクスチャディレクトリに順序付きで差分可能に保ちます。それらのファイルのマージ競合は JSON ファイルの Git 競合:union は壊れた JSON を書き、キーでマージするを参照してください。
8. カナリア展開:次元で広げ、指標で止める
互換性問題はユニットテストで稀にしか現れず、カナリアで現れます。露出を狭いところから広いところへ広げます。
| 次元 | 実践 | 何を検証するか |
|---|---|---|
| テナント許可リスト | 内部や親和性の高い顧客に新しい読み取り経路を先に有効化 | 実世界のデータ形状差 |
| クライアントバージョン | すでに新しい形にアップグレードしたクライアントだけに有効化 | 新旧クライアントの並行稼働 |
| トラフィック割合 | 5% → 25% → 100%。各段階で一つの完全な業務サイクルを観察 | ロングテールの呼び出しとキャッシュヒットの挙動 |
少なくとも三つの指標を見ます。API エラー率、契約検証の失敗率、新旧フィールドの読み取り比率。自動停止条件を定義します。たとえば 5xx が 1% を超える、または古いフィールドの読み取りが突然反発する場合にフラグをロールバックします。カナリア中は二重書き込みを保ち、ロールバックがデータ修復ではなく読み取り経路の切り替えだけで済むようにします。
9. 出荷前の互換性チェックリスト
- [ ] 新しいフィールドは任意で、意味的に独立し、古い名前を再利用していない。
- [ ] 消費者が未知のフィールドと未知の enum 値をフォールバックで扱う。
- [ ] バージョンフィールドが文書とともに永続化され、回線では交渉可能。
- [ ] 非推奨期間に告知、
Sunsetヘッダー、観察がある。 - [ ] 古いフィールドの読み取りをログやトレースで特定の呼び出し元に追跡できる。
- [ ] 移行スクリプトが冪等で、バッチ化され、再開可能で、ロールバック経路を持つ。
- [ ] 契約テストが旧消費者のサブセットをカバーし、CI で動く。
- [ ] カナリアに明示的な露出次元と停止指標がある。
FAQ
任意フィールドの追加は常に後方互換ですか?
いいえ。消費者が未知のフィールドを無視し、厳格なスキーマ検証を避けるときだけ互換です。additionalProperties: false を使う消費者や位置で読む消費者は依然失敗します。出荷前に契約テストで許容性を確認してください。
schemaVersion は URL とレスポンスボディのどちらに置くべきですか?
回線ではメディアタイプか URL パスでバージョンを交渉できますが、保存されるレコードは文書内に schemaVersion を持つ必要があります。キュー、オフラインバックアップ、監査は URL を通らず、文書内バージョンだけが再生とバックフィルを支えられます。
非推奨フィールドはすぐ削除できますか?
いいえ。書き込みを止め、削除日を告知し、呼び出し元ごとに読み取りを数えます。期間が閉じ、読み取りがゼロに保たれてから削除してください。削除前に完全な回帰を実行し、隠れた cron ジョブやレポートがまだ読んでいないことを確認します。
移行スクリプトが途中で失敗したら?
冪等なジョブとして設計します。条件付き更新、主キーカーソル、チェックポイント。失敗後はチェックポイントから再開し、繰り返し実行が二度書きしないようにします。ロールバックには古いフィールドとスナップショットを保ち、schemaVersion を戻すだけにしないでください。
データ移行の順序はどうすべきですか?
Parallel Change に従います。まず expand で新旧フィールドを共存させ両バージョンを読めるようにし、履歴レコードを新しい形にバックフィルし、それから contract で古いフィールドと互換分岐を削除します。順序を逆にするとロールバック経路を失い、移行スクリプトは冪等でバッチ化され再開可能でなければなりません。