JSON API データ契約設計の要点
要点:出荷後に変えるのが非常に難しい六つ。レスポンスエンベロープ、エラー形式、ページネーションの形、ID と時刻の表現、null と欠落の意味論、バージョン進化です。

要点:ページネーションや命名の細部を論じる前に、レスポンスエンベロープ、エラー形式、ID と時刻の表現、null と欠落の意味論を契約として固定しましょう。トップレベルは常に data エンベロープで包み、後からページネーションやトレース ID を既存の呼び出し元を壊さずに追加できるようにします。
API 設計に標準の答えはありませんが、「一度選ぶと取り戻しにくい」決定がいくつかあり、最初の一行を書く前に考えておくのが賢明です。
1. レスポンスを包むか包まないか
三つのスタイル:
// スタイル A:裸のレスポンス
{ "id": 1, "name": "山田" }
// スタイル B:data エンベロープ
{ "data": { "id": 1, "name": "山田" } }
// スタイル C:data + メタデータ
{ "data": { "id": 1, "name": "山田" }, "meta": { "requestId": "a1b2" } }
| スタイル | 利点 | 欠点 |
|---|---|---|
| 裸のレスポンス | 最も単純。フロントエンドの階層が一つ少ない | メタデータを付けられない。トップレベル配列は JSON ハイジャックと進化のリスクを持つ |
| data エンベロープ | meta / errors / links を足しやすい |
ネストが一段増え、フロントエンドは res.data.data と書く |
| data + メタデータ | 可観測性が最良 | meta に何を入れるかの厳格な合意が必要 |
推奨:リストも詳細も一律に data エンベロープを使い、トップレベルを配列にしないこと。そうすれば後からページネーションやトレース ID を足しても契約は壊れません。
2. エラー形式:RFC 9457 をコピーする
独自のエラー構造を発明するのは、協業コストの巨大な源です。RFC 9457(RFC 7807 を置き換える)は application/problem+json を定義しています。
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "残高不足",
"status": 422,
"detail": "口座残高は 12.00 元、今回の引き落としには 30.00 元が必要です",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| フィールド | 意味 | 必須 |
|---|---|---|
type |
安定した問題種別 URI | 推奨 |
title |
人間向けの短い要約 | 推奨 |
status |
HTTP ステータスコード | 推奨 |
detail |
今回何が起きたかの具体的な説明 | 任意 |
instance |
問題が起きたリソースインスタンス | 任意 |
| 拡張フィールド | 任意の業務フィールド | 任意 |
拡張フィールド(上の balance / required など)は仕様で許されており、フロントエンドは detail のテキストをパースする代わりに、それらを使って精密なメッセージを組み立てられます。
3. ページネーション:カーソルがオフセットに勝る
// オフセットページネーション
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// カーソルページネーション
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| 観点 | オフセットページネーション | カーソルページネーション |
|---|---|---|
| 実装の難しさ | 低い(LIMIT/OFFSET) |
中程度(安定した並び替えキーが必要) |
| 深いページの性能 | 悪い(オフセットが大きいほど遅い) | 良い |
| データ変化時 | 重複や欠落 | 安定 |
| ページジャンプ | 可能 | 不可能 |
| 総件数が必要 | 追加のカウントが必要 | 不要 |
推奨:管理画面の表(ページジャンプと総件数が必要)にはオフセット、ユーザー向けの無限フィードにはカーソルを使います。
4. ID と時刻
4.1 ID は常に文字列
理由は型の記事で扱いました。JavaScript 側では 2^53 を超える値が静かに精度を失います。
現在の ID が小さな自動増分整数でも、文字列にしておけば後でスノーフレーク ID に切り替える余地が残ります。
{ "id": "1000000000000000001" }
4.2 時刻には RFC 3339 文字列
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- ミリ秒タイムスタンプの数値は使わない。読みにくく、単位(秒/ミリ秒)を頻繁に間違えます。
- タイムゾーンを明示する。
Zかオフセットのどちらかで。タイムゾーンのない「ローカル時刻」は決して使わない。 - フィールドは一律
xxxAtと命名し、一目で時刻点だと分かるようにする。
5. null と欠落
どちらかを選び、ドキュメントに書き込まなければなりません。
| 方法 | 「値なし」の表現 | 「変更しない」の表現 |
|---|---|---|
| 方法 A | フィールドの値が null |
リクエストでフィールドを省略 |
| 方法 B | フィールドが存在しない | リクエストでフィールドを省略 |
方法 A の問題は、PATCH で「省略」と「null として送信」が異なる意味になり、誤解しやすいことです。
方法 B の問題は、フロントエンドの JSON.stringify が値 undefined のフィールドを落とし、たまたま意味論と一致することです。
実践的な推奨:
- レスポンスボディには方法 A(
nullは明確に値なしを意味し、フロントエンドはundefinedを区別する必要がない)。 - リクエストボディ(特に
PATCH)には方法 B を使い、Merge Patch か JSON Patch と組み合わせて意味論を明示する。
6. フィールド命名と構造
| 決定 | よくある選択 | 注意点 |
|---|---|---|
| 命名スタイル | camelCase(JS エコシステム)/ snake_case(Python、データベース) |
プロジェクト全体で統一し、混ぜない |
| enum 値 | 小文字とアンダースコア、"pending_review" |
数値は使わない。読みにくい |
| 真偽値フィールド | isActive / enabled |
status: 0/1 は使わない |
| 配列フィールド | 複数形の名詞、items |
空配列は [] を返し、決して null にしない |
| ネストの深さ | 3 階層以内 | それより深いなら分割するか expand パラメータを提供 |
空配列に null ではなく [] を返すことは非常に重要です。フロントエンドの data.items.map(...) が null でクラッシュしません。
7. 進化の戦略
| 変更 | 互換性を壊すか | 扱い方 |
|---|---|---|
| 任意フィールドを追加 | いいえ | そのまま出荷 |
| 必須フィールドを追加 | はい | 既定値を足すか、バージョンでリリース |
| フィールドを削除 | はい | まず非推奨とマークし、呼び出し元を観察してから削除 |
| フィールドの型を変更 | はい | 新しいフィールド名を足し、移行期間中は二重書き込み |
| enum の値集合を変更 | 場合による | クライアントは未知値のフォールバックを持たなければならない |
| フィールドの意味を変更 | はい | 新しいフィールドとして扱う |
規律の三つの規則:
- クライアントは未知のフィールドに寛容でなければならない(
additionalProperties: falseで自分を縛らない)。 - クライアントは未知の enum 値にフォールバック分岐を持たなければならない。
- 非推奨フィールドは少なくとも一つのメジャーバージョンは残し、レスポンスヘッダーかドキュメントで
Deprecationをマークする。
8. 完全な例
{
"data": [
{
"id": "1001",
"name": "JSON 徹底ガイド",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
このレスポンスは上のすべての慣習を反映しています。data エンベロープ、文字列 ID、文字列の金額、タイムゾーン付きタイムスタンプ、
欠落を意味する null、空配列、カーソルページネーション、可観測性情報の meta。
9. チェックリスト
- [ ] トップレベルは配列でなく、一律に
dataエンベロープを使う。 - [ ] エラー応答は RFC 9457 に従い、安定した
typeを含む。 - [ ] ページネーションの形が業務シナリオに合う。
- [ ] ID は文字列で、時刻は RFC 3339。
- [ ]
nullと欠落の意味論がドキュメントに書かれている。 - [ ] 命名スタイルがプロジェクト全体で統一され、enum は小文字文字列。
- [ ] クライアントが未知フィールドと未知 enum 値のフォールバックを持つ。
- [ ] 破壊的変更はバージョニングか二重書き込みの移行を通す。
FAQ
進化可能な JSON API はどう設計すればよいですか?
リストも詳細も一律に data エンベロープで包み、トップレベルを配列にしないことで、契約を壊さずにページネーションとトレース ID を足せます。ID は文字列、時刻は RFC 3339 を使い、null と欠落の意味論をドキュメントに書きます。新しいフィールドは任意で意味的に独立に保ち、クライアントに未知フィールドと enum 値のフォールバックを持たせます。
独自のエラー形式を発明しないのはなぜですか?
独自のエラー構造を発明するのは協業コストの巨大な源です。RFC 9457 の application/problem+json をコピーし、問題を安定した type で識別し、balance や required のような拡張フィールドを許せば、フロントエンドは detail のテキストをパースせずに精密なメッセージを組み立てられます。
すべてのエンドポイントがカーソルページネーションを使うべきですか?
いいえ。管理画面の表はページジャンプと総件数が必要なのでオフセットの方が合います。ユーザー向けの無限フィードはカーソルを使うべきで、深いページでも安定し、データ変化時の重複や欠落を避けられます。シナリオがページジャンプと総件数を必要とするかで選んでください。