私が関わったどのチームも、結局は独自のAPIレスポンス形式を発明することになります。最初は無害に見えます —
ここに小さなラッパーオブジェクト、そこにカスタムエラーの形 — そして6ヶ月後には4回目のバージョンのエラーパースミドルウェアを書いていて、
コードレビューでdata.userとdata.result.userのどちらが「正しい」パスかを議論しています。
これらすべてを解決する普遍的な標準はありませんが、本番で持ちこたえるパターンと、
絶対に後で噛みついてくるアンチパターンがあります。
設計ドキュメントに実際に書くべき内容を紹介します。
一貫した成功レスポンス
すべてのチームが議論する最初の質問:すべてのレスポンスを{"status": "ok", "data": {...}}のような
エンベロープで包むべきか?正直な答えは — おそらくデフォルトではそうすべきでない。エンベロープは
2000年代初頭、HTTPステータスコードがプロキシやモバイルネットワーク全体で常に信頼できたわけではなかった時代に
より意味がありました。今日、リソース自身に語らせるフラットなレスポンスの方が、ほとんどの場合クリーンです。
エンベロープは、バルク操作が部分的に成功する場合のような、本当に混在したペイロードを返すエンドポイントのために取っておきましょう。
// ✅ Good — flat, direct, the order IS the response
// GET /v1/orders/ord_9kZ2m
{
"id": "ord_9kZ2m",
"status": "fulfilled",
"customer_id": "cus_4xA1p",
"total_amount": 149.99,
"currency": "USD",
"created_at": "2026-03-15T11:42:00Z",
"line_items": [
{ "sku": "HDPHN-BLK-XM5", "quantity": 1, "unit_price": 149.99 }
]
}
// ❌ Avoid — unnecessary envelope adds a layer clients have to unwrap every time
{
"status": "success",
"code": 200,
"data": {
"order": {
"id": "ord_9kZ2m"
}
}
}エンベロープは、リソース自体の一部ではないメタデータを共存させる必要があるときに意味があります —
ページネーションカーソル、トレーシング用のリクエストID、バルクエンドポイントでの部分失敗サマリーなど。単純な
GET /orders/:idでは、注文がレスポンスです。response.idで十分に動くのに、
クライアントにresponse.data.order.idと書かせないでください。参照するスペックが欲しいなら、
JSON:APIはいつ、どのように
エンベロープを使うかを正確に定義する、意見の強いがよく考えられた標準です — 全面的に採用しなくても
読む価値があります。
エラーレスポンス — RFC 7807 Problem Detailsを使う
カスタムエラーの形は、統合で最もよくある痛みの源のひとつです。どのAPIも
少しずつ違うものに行き着きます — {"error": "..."}、{"message": "...", "code": 42}、
{"errors": [...]} — そして、あなたのAPIを消費する各クライアントは、独自のエラーパースロジックを
書かなければなりません。IETFはこれを
RFC 7807 — Problem Details for HTTP APIsで解決しました。
これは、Content-Typeがapplication/problem+jsonのエラー用の一貫したJSON構造を定義する軽量な標準です。
これを採用すれば、エラー形式はドキュメントに手を伸ばさずとも任意の開発者が読めるものになります。
// POST /v1/orders — 422 Unprocessable Entity
// Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation Failed",
"status": 422,
"detail": "The order could not be created because one or more fields are invalid.",
"instance": "/v1/orders/requests/req_7bN3k",
"errors": [
{
"field": "line_items[0].quantity",
"message": "Quantity must be a positive integer."
},
{
"field": "shipping_address.postal_code",
"message": "Postal code is required for US shipments."
}
]
}- 予測可能なパース: クライアントは、人間が読めるメッセージ(
detail)、機械が読めるカテゴリ(type)、そしてボディに反映されたHTTPステータス(status)をどこで見つければよいか常に知っています。 - 設計による拡張性: スペックはフィールドレベルのバリデーション詳細のための
errorsのような追加フィールドを明示的に許可しています — 回避策をする必要はありません。 - ツールのサポート: OpenAPI 3.xは
application/problem+jsonをレスポンスコンテンツタイプとしてサポートしているので、生成されるドキュメントとクライアントSDKはこの形をネイティブで理解します。 typeURIは単なる文字列ではなく、ドキュメントです: エラーを説明する実際のページを指すようにすれば、サポートチケットをセルフサービスの回答で置き換えたことになります。
HTTPステータスコード + JSONボディを一緒に
ステータスコードとJSONボディは冗長ではありません — 異なる役割を果たします。ステータスコードは HTTP層(プロキシ、キャッシュ、ブラウザ、モニタリングツール)に何が起きたかを伝えます。JSONボディはアプリケーション層に伝えます。 両方とも正しくなければなりません。 MDNのHTTPステータスリファレンス は、どのコードが当てはまるかの議論を解決する最速の方法です。チームが最もつまずくのは 400と422(両方ともクライアントエラーですが、422は構文が有効でサーバーが理解したが — セマンティクスが間違っていた、 ことを具体的に意味します)、そして401と403(401は「あなたは誰?」、403は 「あなたが誰か知っている — これはできない」を意味します)です。
// 400 Bad Request — malformed JSON or missing required field at the HTTP level
{
"type": "https://api.example.com/problems/bad-request",
"title": "Bad Request",
"status": 400,
"detail": "Request body is not valid JSON."
}
// 422 Unprocessable Entity — valid JSON, but business rules rejected it
{
"type": "https://api.example.com/problems/insufficient-inventory",
"title": "Insufficient Inventory",
"status": 422,
"detail": "HDPHN-BLK-XM5 has 0 units available; requested 2.",
"instance": "/v1/orders/requests/req_7bN3k"
}
// 404 Not Found — resource doesn't exist (or you don't want to reveal it does)
{
"type": "https://api.example.com/problems/not-found",
"title": "Order Not Found",
"status": 404,
"detail": "No order with ID ord_XXXXX exists in this account."
}- 200 OK — ボディを返す成功したGET、PUT、PATCH
- 201 Created — リソースを作成した成功したPOST;新しいリソースを指す
Locationヘッダーを含める - 204 No Content — レスポンスボディのない成功したDELETEやアクション;JSONは不要
- 400 Bad Request — リクエスト構文が不正、サーバーはパースすらできない
- 401 Unauthorized — 認証情報が欠落または無効
- 403 Forbidden — 認証済みだが許可されていない
- 404 Not Found — リソースが存在しない
- 409 Conflict — 状態の競合(例:重複注文、楽観的ロックの失敗)
- 422 Unprocessable Entity — 構文は有効、セマンティック/ビジネスバリデーション失敗
- 429 Too Many Requests — レート制限ヒット;常に
Retry-Afterヘッダーを含める - 500 Internal Server Error — サーバー側で何かが壊れた;ボディにスタックトレースを漏らしてはいけない
日付と時刻 — 常にISO 8601
Unixタイムスタンプは見た目がきれいです — ただの数値。しかしそれは罠です。1710499200は秒ですか、
それともミリ秒ですか?(両方とも一般的です。JavaScriptのDate.now()はミリ秒を与え、POSIXは
秒を与えます。)タイムゾーンは?コンバーターなしではログで読めません。1970年以前の日付をきれいに表現できません。
そしてまだ移行していないシステムでは、2038年に32ビット整数をオーバーフローします。
ISO 8601文字列は
これらすべてを解決します。UTCを使用し、常にタイムゾーンオフセットを含めてください — 末尾のZや
+00:00のない裸の2026-03-15T11:42:00は曖昧で、最終的にはローカル時刻を仮定する
クライアントでバグを引き起こします。
// ✅ Good — unambiguous, human-readable, timezone-explicit
{
"created_at": "2026-03-15T11:42:00Z",
"updated_at": "2026-04-01T08:15:33Z",
"scheduled_delivery": "2026-03-18T00:00:00Z",
"expires_at": "2026-04-15T23:59:59Z"
}
// ❌ Avoid — ambiguous, unreadable, seconds vs ms confusion
{
"created_at": 1710499200,
"updated_at": 1743494133000,
"scheduled_delivery": "15/03/2026",
"expires_at": "April 15, 2026"
}nullと省略されたフィールド
この2つは同じではなく、混同するとエッジケースでのみ表面化する微妙なバグを生みます。
nullは、フィールドが存在し、サーバーが認識しており、現在の値が「何もない」ことを意味します —
まだ発送されていない注文のfulfilled_atタイムスタンプのように。
フィールドを完全に省略することは、このコンテキストでは適用されないことを意味します —
返品されていない注文のreturn_tracking_numberのように。クライアントが"fulfilled_at": nullを見たら、
そのフィールドがこのリソースのスキーマの一部であり、明示的に未設定であると分かります。フィールドが欠落している場合、
クライアントはこのレスポンスのスコープ外として扱うべきです — これはPATCHで部分更新をしているときに重要です。
nullを送ることは「このフィールドをクリアする」ことを意味し、省略することは「触れない」ことを意味します。
// Order that exists but hasn't shipped yet
// fulfilled_at: null — we know about this field, it's just not set yet
// return_tracking_number: omitted — returns don't apply to this order
{
"id": "ord_9kZ2m",
"status": "processing",
"created_at": "2026-03-15T11:42:00Z",
"fulfilled_at": null,
"shipped_at": null,
"tracking_number": null,
"total_amount": 149.99
}
// PATCH /v1/orders/ord_9kZ2m — cancel the scheduled delivery
// Only include fields you want to change
{
"scheduled_delivery": null,
"status": "cancelled"
}
// "total_amount" is omitted — we're NOT zeroing it out, just not touching itページネーション — オフセットよりカーソル
オフセットページネーション(?page=3&per_page=20)は実装が直感的で説明しやすいですが、
ライブデータでは静かに壊れます。クライアントがページ送りしている間 — ページ2とページ3の間 —
にレコードが挿入されると、アイテムをスキップします。レコードが削除されると、重複が見えます。
頻繁に変わるデータセット(注文、イベント、通知)にとっては、カーソルベースのページネーションが正しいデフォルトです。
クライアントには、結果セット内の位置を表す不透明なカーソル(通常はbase64エンコードされたIDまたはタイムスタンプ)
を与えます。次のページは、挿入や削除に関係なく、その正確な地点から始まります。オフセットページネーションは、
データセットが安定していてユーザーが本当にページ47にジャンプする必要がある管理UIには問題ありません。
無限スクロールをするモバイルクライアントには問題があります。
// GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF85a1oybSJ9
{
"orders": [
{ "id": "ord_9kZ2m", "status": "fulfilled", "total_amount": 149.99, "created_at": "2026-03-15T11:42:00Z" },
{ "id": "ord_8jY1l", "status": "processing", "total_amount": 89.00, "created_at": "2026-03-14T09:10:00Z" }
],
"pagination": {
"next_cursor": "eyJpZCI6Im9yZF84alk1bCJ9",
"has_more": true,
"limit": 20
}
}
// When has_more is false, omit next_cursor entirely (or set to null)
// Clients: fetch next page with ?cursor=<next_cursor> until has_more === falseフィールド命名 — snake_case vs camelCase
ひとつの慣例を選び、リンターで強制しましょう。実際の選択よりも
一貫性が重要です。そうは言っても:主要な消費者がJavaScript/TypeScriptクライアントなら、
camelCaseは分割代入やオブジェクトスプレッドとクリーンに統合できます。
主要な消費者がPythonやRubyのバックエンドなら、snake_caseが自然に感じられます。
両方を提供する場合、実用的な解決策は慣例をドキュメント化し、クライアントに変換層 —
reviver付きのJSON.parse、
Pythonのhumpsライブラリ、またはフレームワーク内の単一のシリアライゼーション設定 — を使わせることです。
絶対にすべきでないことは、同じAPI内で慣例を混ぜることです — order_totalの隣にcustomerIdがあるのは、
異なるエンジニアが互いに話さずに異なるエンドポイントを書いた兆候です。
エンドポイント間でフィールド名を一貫してドキュメント化するには
JSON Schema Generatorを使いましょう。
バージョニング
2つの流派:URLバージョニング(/v1/orders、/v2/orders)と、
ヘッダーバージョニング(Accept: application/vnd.example.v2+jsonまたはカスタムAPI-Version: 2026-03-15
ヘッダー)。実際にはURLバージョニングがほぼ毎回勝ちます。ヘッダーをパースせずにログで見え、
設定なしで全てのHTTPクライアントで動作し、ブラウザでテストでき、単純なパスプレフィックスルールで同じゲートウェイで
v1とv2を並行して動かせます。ヘッダーバージョニングは
IANAメディアタイプ
モデルに従えば理論的にはよりRESTfulですが、目に見えない複雑さを生み出します — URLでは同一に見えるリクエストが、
ほとんどの開発者が最初にチェックしないヘッダーに依存して実際には異なる挙動をしているのです。
Stripeの日付ベースのバージョニング(Stripe-Version: 2024-06-20)は大規模プラットフォームには
両方の世界のいいとこ取りですが、それは最初のバージョンスキームを選ぶ問題とは別物です。
何を選ぶにせよ、初日からバージョニングしてください。本番でバージョニングされていないAPIにバージョニングを後付けするのは
苦痛で、きれいに進むことは稀です。マイグレーションテスト中に両方のAPIバージョンからのレスポンスが構造的に
健全であることを確認するには、JSONバリデーターを使いましょう。
まとめ
これらは何も画期的ではありません — しかしそれこそがポイントです。API設計で最も苦労するチームは、 技術的に間違った選択をしたチームではありません。異なるエンドポイントで異なる選択をして、 それを書き留めなかったチームです。フラットな成功レスポンス。RFC 7807エラーボディ。ISO 8601の日付。 ライブデータにはカーソルページネーション。「既知で空」にはnull、「適用されない」には省略。URLバージョニングを初日から。 これらのパターンは完璧ではありませんが、予測可能です — そして予測可能性こそが、 APIをリバースエンジニアリングするパズルではなく、統合する喜びにするものです。 スペックレベルの議論を解決する必要があれば、正式なJSON仕様は RFC 8259にあります。 その層より上のすべてについて、最良の標準は、あなたのチームが実際に書き留めて一貫して従うものです。