5年もつJSON APIレスポンスの形
苦しいAPI移行のほとんどは、ある午後に下され、最初の連携によって凍結された「形」の決定にさかのぼれます。
このページの記述はすべて実測か出典付きです。そのどちらでもない場合は、そのことを明記しています。
誰かのv1で世に出て、いまも出続けているレスポンスがこれです。
[
{ "id": 8102, "name": "Ada" },
{ "id": 8103, "name": "Grace" }
]
3年後、コレクションはページングが必要な大きさになり、カーソルを置く場所がありません。トップレベルが配列だからです。包めばすべてのクライアントがすでにパースしている型が変わるので、チームは/v2/usersを出し、2つのコードパスを永遠に保守します。この配列は、書かれた時点では何も間違っていませんでした。ただ、育つ余地がなかったのです。
話はそれに尽きます。レスポンスの設計は優雅さの問題ではなく、どの変更が安いままでいられるかの問題です。
エンベロープか、裸の値か
エンベロープとは、ペイロードをキーの下に置いたトップレベルのオブジェクトのことです。
{
"data": [ { "id": "8102", "name": "Ada" } ],
"nextCursor": "eyJpZCI6ODEwM30",
"hasMore": true
}
反対論には根拠があります。ノイズですし、どのクライアントも.dataと書くことになります。賛成論は、オブジェクトは拡張できるが裸の配列はできない、という点です。あとからカーソル、総数、非推奨の通知、トレースIDを足しても、すでにあるものの型は何ひとつ変わりません。
私のやり方は、コレクションは包み、単一リソースは裸のオブジェクトを返す、というものです。単一リソースはすでにオブジェクトなので、包みが与えてくれるはずだった育つ余地を最初から持っています。コレクションを包むのは、いずれメタデータが必要になるのがそちらだからです。
どちらを選ぶにせよ、一度で決めてください。エンドポイントの半分が包まれ半分が裸というのは、どちらか一方に統一するより悪い状態です。そしてdataという名前のフィールドの中にdataという名前のフィールドを入れないこと。
後戻りできないフィールドの型
IDは文字列。 常にです。まだ小さな整数であるうちからそうします。JavaScriptにおけるJSONの数値はIEEE 754のdoubleなので、9007199254740991を超える識別子は到着時に黙って丸められ、あなたが見ているのは別のレコードになります。Twitterは2010年に64ビットのSnowflake IDへ移行した際にこれにぶつかり、idの隣にid_strを出しました。以来その形が定着しています。仕組みはJSONのIDが勝手に値を変える理由にあります。設計上の要点はもっと狭い話です。識別子は数量ではありません。足し算もしなければ、算術的に並べ替えることも、平均を取ることもない。数値型は何も与えてくれず、UUIDへ移行する日に代償を払わせます。
金額は最小単位の整数か、十進の文字列。 決して浮動小数点数にしないこと。
{ "amountMinor": 1005, "currency": "GBP" }
{ "amount": "10.05", "currency": "GBP" }
1.005はdoubleで正確に表せないので、JavaScriptでは1.005 * 100が100.49999999999999となり、101ではなく100に丸められます。表現をひとつ選び、通貨をその隣に置き、裸のprice: 10.05をスキーマに入れないでください。あとで取り除くとは、それを使って計算しているすべての消費側を監査するということです。
日付はオフセットを明示したRFC 3339の文字列。 "2026-09-05T14:30:00Z"のように。Unixタイムスタンプでも"05/09/2026"でもなく、とりわけオフセットのない現地時刻にはしないこと。それは何事もなくパースされ、そして数時間ぶん間違っています。JSONに日付型はないので、この慣習はコードレビューが強制して初めて存在します。残りはJSONの日付・時刻フォーマットが扱います。
null、不在、空
4つの形、4つの意味。
| 形 | 意味 |
|---|---|
"middleName": "Jane" |
値が分かっている |
"middleName": null |
値がないと分かっている |
| キーがない | 不明、未読み込み、または権限がない |
"tags": [] |
タグが0個だと分かっている |
間違いは、誤った慣習を選ぶことではなく、4つを一貫性なく使うことです。そうなると、クライアントは「このユーザーにミドルネームはない」と「あなたが部分的な射影を要求した」を区別できません。フィールドごとに決め、その線を守ってください。
罠が2つ。JSON.stringifyは値がundefinedのキーを落とし、nullは残します。したがってJavaScriptの生成側は、変数に代入されたかどうかで不在とnullのあいだを行き来します。そしてJSON Schemaのrequiredが主張するのはキーが存在することであって、非nullであることではありません。{"name": null}はrequired: ["name"]を満たします。非nullを意味したいなら、型のほうに書いてください。
{
"type": "object",
"required": ["name", "middleName"],
"properties": {
"name": { "type": "string" },
"middleName": { "type": ["string", "null"] }
}
}
最初の草案はスキーマ生成ツールで実際のペイロードから作り、そのあとnull許容性を手で直してください。生成器が見られるのは、たまたま標本に入っていた値だけです。
命名
camelCaseかsnake_caseを選び、すべてのエンドポイントのすべてのキーに適用し、その議論を終わらせてください。ひとつの文書内で大文字小文字の流儀が混在しているのは、2つのチームが半分ずつ書いて互いに読まなかったことの、いちばん分かりやすい合図です。キーを機械的に構造体のフィールドへ対応づけるという安上がりな手も使えなくなります。tsよりcreated_at。コメントを必要とするキーは、もっとよい名前を必要としています。
エラー
エラーの本文には別々の3つが必要で、多くは1つしか出しません。
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 402,
"detail": "Balance is 320 minor units, transfer requires 1005.",
"code": "INSUFFICIENT_FUNDS",
"pointer": "/transfer/amountMinor"
}
クライアントが分岐に使う安定した機械可読コード。これは決して変えないと約束するものです。人間向けのメッセージ。言い回しを変えたり翻訳したりする自由を保ち、どのクライアントもこれに対して一致判定をすべきではありません。そして問題のフィールドを指すポインタ。リクエスト本文に対して機械的に解決できるよう、できればRFC 6901のJSON Pointerで。
RFC 9457(Problem Details for HTTP APIs)はtype、title、status、detail、instanceを標準化し、拡張メンバーを明示的に許しているので、採用したうえで自前のcodeを持ち続けられます。既存の実装の多くが知っている名前であるRFC 7807を廃止したのがこれです。これを使えば、他人のツールがすでに理解している形が手に入ります。{"error": "something went wrong"}では決して手に入りません。バリデーション失敗は、最初の1件ではなく全件を返してください。
カーソルはオフセットに勝る
オフセットによるページングは、同時書き込みが起きた瞬間から静かに情報を落とします。1ページ目が1行目から50行目を返す。上のほうに1行が挿入される。2ページ目のoffset=50は、いまや元の50行目から始まるので、消費側は同じレコードを2回見ます。削除は逆向きに働き、レコードを丸ごと飛ばします。エラーは何も出ません。数週間後、突合の不一致として表に出ます。
カーソルは安定した並び順における位置を符号化します。ふつうは並べ替えキーと同値を割るためのidの組で、その上に挿入が起きても関係ありません。あとで符号化を変えられるよう、カーソルは不透明なものとして文書化し、短いページから終端を推測させる代わりにhasMoreを明示して返してください。totalCountは、本当に必要とする人がいて、2つ目のクエリのコストを払う気があるとき以外は省きます。
追加だけが無料の変更
進化を可能にする契約はクライアント側にあります。未知のフィールドは無視しなければならない、というものです。これが守られていればフィールドの追加は破壊的変更ではなく、継続的に出荷できます。消費側が厳密に検証していたり、additionalProperties: falseで型を生成していたりすれば、追加のたびに誰かが壊れ、あなたは永遠にv1のままです。ドキュメントの最初の段落にそう書いてください。
それ以外はすべてバージョンです。フィールドの削除、改名、型の変更、値の意味の変更、受け入れ範囲の厳格化、null許容フィールドの非null化。前回リリースのサンプルと今回のサンプルをJSON差分に通せば、誰も意図していなかった型の変化を捕まえられます。
異種混在の配列は、節約分より消費側のコストのほうが大きい
{ "items": [
{ "kind": "comment", "body": "..." },
{ "kind": "reaction", "emoji": "..." },
{ "id": 7, "legacy": true }
] }
これでどの消費側もディスパッチを書くことになり、静的型付けの消費側はタグ付き共用体を手で書くことになります。どうしても形を混ぜるなら、判別できるようにしてください。すべての要素に存在する、文書化された閉じた値集合を持つ必須のkindを置くのです。そうすれば共用体は機械的に導けます。許しがたいのは3つ目の要素で、タグなしに形が変わり、クライアントがキーを嗅ぎ回る羽目になります。あるフィールドが時に文字列、時にオブジェクトになるのも同じ。バージョンをひとつ上げる手間を省く代わりに、すべてのクライアントに永遠の型ガードを負わせます。
レスポンスが大きくなったら
どのエンジンにも文字列長の硬い上限があり、それは思われているより低い値です。64ビットのV8(ChromeとNode)では536,870,888文字なので、おおよそ0.5GBを超えるレスポンスは文字列として保持することすらできず、パース以前の問題になります。ほかのエンジンはもっと高いところにありますが、いずれも上限を持ちますし、パース後のオブジェクトの木はテキストの何倍ものコストになります。そのはるか手前で、数秒かかるパースがメインスレッドを止めます。
出口は3つ、APIを乱す度合いの小さい順に。ページングを細かくして、単一のレスポンスが大きくならないようにする。行区切りのレコードをストリーミングし、閉じ括弧を待たずに受け取りながら処理させる(NDJSONとJSON Lines)。あるいは一括エクスポートを同期APIから完全に切り離し、ジョブIDと完成ファイルへの署名付きURLを返す。消費側の話は巨大なJSONファイルの扱いにあります。
チェックリスト
- コレクションは包み、単一リソースは裸のオブジェクトを返し、一貫させる。
- IDは文字列。金額は最小単位か十進文字列。日付はオフセット付きのRFC 3339。
- null、不在、空がそれぞれ何を意味するか、フィールドごとに定義する。
- すべてのエンドポイントで大文字小文字の流儀をひとつに。
- エラーは安定したコード、変えてよいメッセージ、フィールドへのポインタを持つ。RFC 9457を検討する。
- カーソルによるページング、不透明なカーソル、明示的な
hasMore。 - 未知のフィールドは無視するようクライアントに伝え、それ以外のあらゆる変更はバージョンの後ろに置く。
- 異種混在の配列はすべて、必須の
kindで判別できるようにする。
どれも初日には高くつきません。すべてが1000日目に高くつきます。いま議論する価値があるのは、その一点においてです。