この記事は、バックエンドの API を設計したときの経験をまとめたものである。下敷きにしたのは主に Stripe の API で、個々の判断は AI(Claude)と議論しながら詰めた。
決めたことはチームの設計ガイドとして文書にまとめた。このシリーズでは、そこで選んだ方針と、その代わりに何を諦めたかを 4 回に分けて書く。1 回目は URL の設計を扱う。
URL は名詞、操作は HTTP メソッド
RESTful な設計では、URL はリソースを指し、そのリソースに何をするかは HTTP メソッドが表す。
GET /v1/customers # 一覧取得
POST /v1/customers # 作成
GET /v1/customers/:customer_id # 取得
POST /v1/customers/:customer_id # 更新
DELETE /v1/customers/:customer_id # 削除
URL には複数形の名詞でリソースだけを書き、操作は HTTP メソッドで表す。 操作が増えても URL の語彙は増えない。
不要なネストはしない
顧客の注文一覧は、顧客のサブリソースとして書ける。しかしこの API ではネストしない。
# 避ける
GET /v1/customers/12345/orders
# こうする
GET /v1/orders?customer_id=12345
注文は必ず 1 人の顧客に属し、所有者は顧客である。それでもネストしないのは、顧客をまたいで注文を扱う要件があるからである。
全顧客の注文を横断して検索する、複数顧客の注文をまとめてキャンセルする。/v1/customers/:customer_id/orders ではこれを表現できない
トップレベルに置くかサブリソースにするかは、所属関係ではなく、親をまたいで扱う要件があるかどうかで決まる。
注文明細も同じ基準で判断する。
GET /v1/orders/:order_id/items # 注文をまたいで明細を扱う要件が無い場合
GET /v1/order_items?created_after=... # 注文をまたいで明細を検索・集計する要件がある場合
関連するだけのリソースを親子にしない
独立したリソースは他のリソースの下に押し込めず、トップレベルに置く。
# 避ける
GET /v1/notifications/categories
# こうする
GET /v1/notification_categories
通知とカテゴリは関連するが、親子ではない。カテゴリは通知が 1 件も無くても存在し、それ自体を一覧・作成・更新する。 親の下に置くと /v1/notifications/:notification_id と紛らわしく、categories が ID の位置に現れる。フラットにすればこの衝突は起きない。リソース名がそのまま仕様書の単位にもなる。
更新はすべて POST にする
新規に設計する API では、更新系の操作をすべて POST にし、PUT と PATCH は使わない。使うメソッドは GET / POST / DELETE の 3 つだけである。
同じ POST でも、URL にリソース ID があるかどうかで作成と更新を区別する。
- 作成: コレクションに
POSTする(POST /v1/customers) - 更新: 既存リソースの URL に
POSTする(POST /v1/customers/12345)
理由は 3 つある。
- 作成・更新・アクションが 1 つのメソッドに揃い、ルーティング・ログ・アクセス制御の分岐が減る
- 設計のたびに
POST/PUT/PATCHのどれを使うか迷わなくなる - 参考にした Stripe API Reference も
GET/POST/DELETEしか使っていない
ただし POST に統一すると、リクエストを見ただけでは全体置換なのか部分更新なのかが分からない。「送らなかったフィールドは変更されない」を全リソース共通の規約として明記する。
PUT と PATCH を使う設計も正しい
意味論を守るなら、PUT と PATCH を使う設計も正しい選択である。
-
PUT: リソース全体の置換。送らなかったフィールドはクリアされる。小さく自己完結したリソースや、スター・フォローのようなトグル的なサブリソースに向く -
PATCH: 部分更新。送らなかったフィールドは変わらない
避けたいのは、同一リソースに PUT と PATCH の両方を用意することである。 クライアントはどちらを使うべきか迷い、サーバーは同じ更新処理を 2 つの意味論で持つことになる。リソースの性質に応じて、どちらか一方に絞る。
パスは snake_case にする
複数単語のリソース名はアンダースコアで区切る。
GET /v1/customer_groups?created_after=2026-01-01&page_size=20
kebab-case も選択肢であり、API 全体で統一されていれば問題ない。それでも snake_case を選ぶのは、JSON のフィールド名とクエリパラメーター名がすでに snake_case だからである。/v1/customer-groups?created_after=... と書くと、1 つの URL に 2 種類の区切り文字が混ざる。
パスパラメーターの名前は「リソース名 + _id」に揃え、仕様書では :customer_id とコロン付きで書く。
CRUD に収まらない操作
「有効化する」「配信を止める」のような操作は、CRUD に素直には収まらない。動詞をパスに足す前に、次の順で検討する。
サブリソースとして表す
状態そのものをリソースとみなせる場合は、サブリソースの作成・削除で表す。GitHub API の gist へのスターが、/gists/:gist_id/star の作成・削除として表現されているのが例である。
動詞のサブパスを使う
サブリソースで表せない状態遷移だけ、リソース配下の動詞サブパスにする。
POST /v1/notifications/:notification_id/cancel
Stripe も同じスタイルで、Charge の確定は POST /v1/charges/:id/capture、PaymentIntent の確定は POST /v1/payment_intents/:id/confirm である。先にサブリソースで表せないかを検討し、それでも収まらないものだけ動詞にする。この順番自体が方針である。
リクエストボディで操作を分岐しない
action フィールドで操作を切り替える方式は採らない。
POST /v1/notifications/12345
{ "action": "cancel" }
この形だと URL から操作が分からない。 アクセスログには同じパスしか残らず、権限制御もルーティングもボディの中身を見て分岐することになる。
リソースに紐付かない操作
複数リソースをまたぐ検索のように、どのリソース配下にも収まらない操作もある。これは無理に押し込めず、/v1/search のようなトップレベルのエンドポイントとして定義する。利用者にとって自然な形を優先し、例外であることを仕様書に書く。
次回は、レスポンスと一覧 API の設計を書く。