メインコンテンツへスキップ
ブログ一覧に戻る
Web制作・運用

API設計のベストプラクティス:RESTful、GraphQL、gRPCの選定基準と実践

2026年1月15日
最終更新: 2026年10月8日
12分で読めます

この記事でわかること

REST、GraphQL、gRPCを利用者・通信形態・運用条件で比較し、APIの契約を具体化。HTTPメソッドとエラー、権限、入力、再試行、負荷上限、キャッシュ、版管理を解説します。完結したGraphQLのローカル例で型と業務検証の違いを確認し、公開前の異常系、互換性、期限、監視まで判断できる実践ガイドです。

2026年10月8日に、DataLoaderの現行公式READMEを再読しました。DataLoaderはGraphQLなどのデータ取得層で、同じイベントループ内の個別loadをまとめてbatch関数へ渡します。batch関数は入力キーと同じ長さ・対応順の値を返す必要があり、標準のcacheは通常1リクエスト内のメモ化で、Redisなどの共有アプリケーションキャッシュの代替ではありません。利用者ごとに権限が異なるならリクエストごとにインスタンスを作る設計が必要で、導入だけでN+1、認可、実測レイテンシが解決するわけではありません。

30秒で要点

API設計では、最初に利用者、操作するデータ、失敗時の扱いを決めます。REST、GraphQL、gRPCの選択は、その契約をどの形で提供するかの判断です。方式名だけで安全性や速さは決まりません。

  • 外部の多様な利用者へHTTPで公開するのか、自社の画面に必要な項目を選ばせるのか、管理されたサービス同士を接続するのかを分けます。
  • 正常な応答だけでなく、権限不足、入力不備、再試行、負荷上限、互換性を設計します。
  • スキーマや型が合っていても、その人がそのデータを操作してよいかは別に検証します。

この記事は、CRUD中心のHTTP API、関連データを読むGraphQL、サービス間のgRPCを比較し、仕様書と小さなローカル例へ落とすためのガイドです。公式資料は2026年10月4日に確認しています。掲載コードのローカル確認と、実ネットワーク・認証基盤・負荷試験は区別します。

方式を選ぶ前に、呼び出しの条件をそろえる

たとえば「会員情報と投稿を画面に表示する」という目的でも、公開API、社内管理画面、サービス間連携では必要な互換性や運用が違います。次の項目を短く記録してから方式を比較します。

表が収まらない場合は、横にスクロールできます

決めること確認例
利用者と更新の自由度外部顧客のクライアントを一斉更新できるか
データの形固定の一覧か、画面ごとに関連項目が変わるか
通信の形1回の要求と応答か、継続的なストリームか
待てる時間と失敗タイムアウト後の再試行で二重処理にならないか
守る境界組織、所有者、役割、公開範囲をどこで判定するか
運用誰が仕様、監視、障害対応、廃止連絡を担当するか
APIの利用条件からREST、GraphQL、gRPCを比較し、全方式で権限、入力と負荷、失敗と再試行、互換性を確認する図。方式の選択と安全な運用の契約を別の段階に配置している。
図1:方式は利用条件に合わせて選び、その後も共通の契約を確認します。図の並びは性能順位ではなく、いずれの方式にも実装・検証する責任があります。

表が収まらない場合は、横にスクロールできます

方式検討しやすい条件選ぶ前に確認する負担
RESTを意識したHTTP APIリソース中心の操作、多様なHTTP利用者、HTTPキャッシュの活用応答の粒度、複数取得の回数、エラーと版管理
GraphQL画面ごとに必要項目や関連データが異なるリゾルバー、フィールド権限、クエリ負荷、キャッシュ設計
gRPC管理されたサービス間、型付き契約、ストリームコード生成、プロキシ対応、期限・キャンセル、運用調査

GraphQLは要求したフィールドを取得できますが、DB側の処理まで自動的に減るとは限りません。gRPCも、通信形式だけでアプリケーション全体が速くなるとは言えません。同じ入力、応答内容、認証、ネットワーク、負荷で比較して判断します。

gRPCの公式説明には、単一応答、サーバー・クライアントそれぞれのストリーミング、双方向ストリーミングがあります。ブラウザから利用する場合は通常のサービス間接続と同じと考えず、gRPC-Webの例のようなプロキシ構成や、採用実装が対応する通信方式を確認します。

HTTP APIは、URLとメソッドを一つの契約にする

RESTはHTTPのメソッド名を使うだけの規則ではありません。ここでは実務でよく扱うリソース中心のHTTP APIに範囲を絞ります。ステートレスとは、要求を理解するための文脈を要求ごとに扱う設計であり、DBに会員情報や注文の状態を保存してはいけないという意味ではありません。

表が収まらない場合は、横にスクロールできます

操作設計例確認する契約
一覧取得GET /users?limit=20件数の上限、並び順、次ページ
1件取得GET /users/123存在しない場合と閲覧権限
新規作成POST /users必須項目、重複、作成先の通知
置換PUT /users/123省略した項目をどう扱うか
部分更新PATCH /users/123差分の形式、更新可能な項目
削除DELETE /users/123論理削除か、関連データはどうするか

部分更新のPATCHでは、RFC 5789に沿って差分の形式を定義します。PATCH自体が常に冪等であるとは限りません。

GET /deleteUser?id=123のようにGETで削除を実行する設計は避けます。HTTPの意味を定めるRFC 9110では、GETなどの安全なメソッドと、状態変更を意図する操作を区別しています。

冪等(べきとう)とは、同じ要求を繰り返したときの意図した効果が一度の場合と同じであることです。PUTやDELETEの応答コードが毎回同じという意味ではありません。POSTで注文を作る場合などは、タイムアウトしたからと無条件で再送せず、重複防止キーの範囲・保存期間・同時要求時の扱いを設計します。クライアントが応答を受け取れなくても、サーバーでは処理が終わっている可能性があります。

成功と失敗を、利用者が判断できる形で返す

表が収まらない場合は、横にスクロールできます

状態代表的なHTTPステータス利用者に必要な情報
取得成功200契約した形式の本文
作成成功201作成したリソース、必要に応じたLocation
本文なしの成功204本文を返さないこと
要求形式や入力の不備400、意味上の不備には422を使う設計もある修正可能な項目と理由
有効な認証情報がない401適切なWWW-Authenticateチャレンジ
操作を許可しない403安全に説明できる拒否理由
対象が見つからない404存在の開示方針と一貫した扱い
現在の状態と競合409再取得や調整の方法
予期しないサーバー障害500問い合わせ用の識別子、機密を含まない説明

次は架空ユーザーを返すJSONの例です。日時の表現、IDを文字列にするか、nullと省略の意味なども仕様に含めます。

{
  "id": "123",
  "name": "Example User",
  "email": "user@example.com",
  "createdAt": "2026-01-15T09:00:00Z"
}

エラーは、RFC 9457のProblem Detailsを使う選択肢があります。以下はHTTPステータス400、Content-Typeがapplication/problem+jsonの本文例です。errorsはこのAPI独自の拡張であり、規格共通の必須フィールドではありません。

{
  "type": "https://example.com/problems/validation",
  "title": "Validation failed",
  "status": 400,
  "detail": "入力項目を確認してください。",
  "errors": [{ "field": "name", "code": "required" }]
}

example.comは説明用のドメインで、実際の問題仕様を公開する場所に置き換えます。本文のstatusと実際のHTTPステータスを一致させ、利用側は自由文のdetailを解析して分岐するのではなく、typeや定義した拡張コードを使います。スタックトレース、トークン、DBの内部情報をそのまま返しません。

GraphQLは、スキーマと業務上の検証を両方用意する

GraphQLのスキーマは、取得可能な型やフィールド、引数を定義します。公式の型システムの説明が示すように、要求はスキーマに対して検証されます。しかし、Intが型として正しくても、一覧件数に負の値や過大な値を許すかは別の判断です。

以下は3ファイルで完結する、ローカルの架空データ例です。空の練習用ディレクトリでNode.jsとgraphqlパッケージを用意し、各コードを指定名で保存してnode demo.mjsを実行します。HTTPサーバー、DB、外部認証は起動しません。実サービスの認可実装ではなく、定義・入力・応答の関係を確かめる例です。

まずschema.graphqlです。作成・更新の入力型も定義し、元のユーザーと投稿の関係を残しています。

type Post { id: ID!, title: String! }
type User { id: ID!, name: String!, posts(limit: Int = 2): [Post!]! }
input CreateUserInput { name: String! }
input UpdateUserInput { name: String! }
type Query { user(id: ID!): User }
type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User
}

次はquery.graphqlです。メールアドレスなど、画面に不要な項目を要求しない形にします。

query ReadUser($id: ID!) {
  user(id: $id) {
    id
    name
    posts(limit: 1) { id title }
  }
}

最後にdemo.mjsです。メモリ内のデータとテスト用の権限を使い、名前の空白と一覧の上限を型検証とは別に確認します。

import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { buildSchema, graphql } from 'graphql';

const schema = buildSchema(await readFile('schema.graphql', 'utf8'));
const source = await readFile('query.graphql', 'utf8');
const users = new Map([['1', { id: '1', name: 'Example', posts: [
  { id: 'p1', title: 'First' }, { id: 'p2', title: 'Second' }
] }]]);
let nextId = 2;
const fixtureContext = { mayRead: true, mayWrite: true };
function allow(context, permission) {
  if (context?.[permission] !== true) throw new Error('Forbidden');
}
function nameOf(input) {
  const name = input.name.trim();
  if (name.length < 1 || name.length > 80) throw new Error('Invalid name');
  return name;
}
function view(user) {
  if (!user) return null;
  return { id: user.id, name: user.name, posts: ({ limit = 2 }) => {
    if (!Number.isInteger(limit) || limit < 1 || limit > 10) {
      throw new Error('limit must be 1..10');
    }
    return user.posts.slice(0, limit);
  } };
}
const rootValue = {
  user: ({ id }, context) => {
    allow(context, 'mayRead');
    return view(users.get(id));
  },
  createUser: ({ input }, context) => {
    allow(context, 'mayWrite');
    const name = nameOf(input), id = String(nextId++);
    const user = { id, name, posts: [] };
    users.set(id, user);
    return view(user);
  },
  updateUser: ({ id, input }, context) => {
    allow(context, 'mayWrite');
    const name = nameOf(input), user = users.get(id);
    if (!user) return null;
    user.name = name;
    return view(user);
  }
};
const run = (text, variables = {}, context = fixtureContext) => graphql({
  schema, source: text, rootValue, variableValues: variables, contextValue: context
});
const result = await run(source, { id: '1' });
assert.equal(result.errors, undefined);
assert.equal(result.data.user.posts.length, 1);
assert.equal(result.data.user.posts[0].title, 'First');
const denied = await run(source, { id: '1' }, { mayRead: false });
assert.equal(denied.errors[0].message, 'Forbidden');
const invalid = await run('{ user(id:"1") { posts(limit:0) { id } } }');
assert.equal(invalid.errors[0].message, 'limit must be 1..10');
const created = await run('mutation { createUser(input:{name:"New"}) { id } }');
assert.equal(created.data.createUser.id, '2');
const updated = await run('mutation { updateUser(id:"2",input:{name:"Changed"}) { name } }');
assert.equal(updated.data.updateUser.name, 'Changed');
console.log('取得・上限・拒否・作成・更新の契約を確認しました');

実サービスでは、クライアントが送ったmayReadを信用してはいけません。認証した主体からサーバー側で権限を決め、組織・所有者・フィールドごとに判定します。複数の入口で同じ業務規則が働くように、GraphQLの認可ガイドが勧める業務層へ集約します。

取得する項目が少なくても、負荷上限は必要

一覧の各要素について追加クエリを実行すると、N+1問題が起こります。GraphQLの性能ガイドが説明するバッチ取得を検討できますが、DataLoaderの要求単位キャッシュのように、権限が違う利用者間で共有しない設計が必要です。バッチ化だけでは、深い入れ子や大量の別名フィールドによる負荷は消えません。

件数、深さ、幅、実行コスト、処理時間を組み合わせて制限します。GraphQLのセキュリティガイドでも、これらを別の対策として扱っています。Subscriptionも通信や再接続、認可更新が自動で完成する機能ではありません。選ぶWebSocketやSSEなどの実装に合わせて、接続管理を設計します。

安全性は認証、認可、入力、負荷に分ける

認証は誰からの要求かを確かめること、認可はその操作を許すかを判断することです。JWTはトークンの形式であり、採用しただけで認可が成立するわけではありません。OWASPのRESTセキュリティ指針に沿い、HTTPS、署名などの完全性、発行者、宛先、有効期限を確認します。

実装レビューでは、正常な利用者だけでなく「別組織のIDを指定」「閲覧のみの人が更新」「更新不可のフィールドを追加」「同じ作成要求を再送」を試します。エンドポイントへ到達できることと、個々のデータへアクセスできることは別です。

入力の型・長さ・範囲は入口で確認します。そのうえで、SQLはパラメーター化し、出力先に合うエンコードやHTMLのサニタイズを行います。入力検証一つでSQLインジェクションやXSSがすべて防げるとは考えません。許可したフィールドだけを更新することも重要です。

レート制限では、利用者、組織、処理の重さ、外部API費用などの単位を決めます。IP単位だけでは、共有回線の利用者をまとめて拒否したり、分散した要求を十分に抑えられなかったりします。拒否件数と正常利用への影響を見て調整し、これだけでDDoS対策が完了したとしません。

キャッシュと一覧取得は、正しさを先に守る

HTTPキャッシュ、アプリケーション内のRedis、CDNは別の層です。どのキーで何を保存し、更新・削除・権限変更後にいつ無効化されるかを決めます。認可を通る前に共有キャッシュから個人データを返すような経路を作らないようにします。

RFC 9111では、no-cacheは再利用前の検証、no-storeは保存禁止、privateは共有キャッシュで保存しない指定です。どれも同じ意味ではなく、機密保護全体の代わりにはなりません。公開データでも、ETagと条件付き要求、更新時の無効化を含めて確認します。

一覧取得は、件数上限と安定した並び順を必須の契約にします。offset方式は扱いやすい一方、途中の追加・削除で重複や抜けが起こり得ます。cursor方式でも、何を基準に継続するか、同じ時刻の行をどう並べるか、カーソルの改変をどう扱うかが必要です。

DBの索引や読み取りレプリカは、実行計画と負荷を見て選びます。レプリカの遅れがあれば、更新直後の取得に古い値が返る場合があります。「キャッシュすれば速い」という話だけでなく、許される古さと整合性を合意します。

版管理、期限、監視を公開前に決める

表が収まらない場合は、横にスクロールできます

版の伝え方例移行時の注意
URL/v1/users、/v2/users分かりやすいが、並行運用するURLが増える
ヘッダーAccept: application/vnd.example.v2+json利用者や中間キャッシュが表現の違いを扱えるか
クエリ/users?version=2既定値、ログ、キャッシュキーの扱いを統一する

どれかを採用すれば互換性が保たれるわけではありません。項目削除、型変更、null許容の変更、列挙値の追加などが既存クライアントへ及ぼす影響を確認します。GraphQLも、非推奨表示を付けるだけで利用者が移行するとは限りません。利用状況、告知、移行期限、旧版停止の判断を残します。

呼び出し期限も契約です。gRPCの期限ガイドでは、既定で期限を設けないことと、サーバー側で開始した処理の停止はアプリケーションの責任であることを説明しています。短すぎる期限や無制限の再試行は負荷を増やすため、実測に基づく期限、回数上限、待機、キャンセル伝播を確認します。

公開前は、仕様に対する正常・異常応答、別主体の拒否、入力境界、一覧の上限、再試行、旧クライアントの互換性を確認します。監視にはエラー率と処理時間だけでなく、拒否、タイムアウト、依存先の失敗、費用につながる使用量を含めます。ログには秘密情報を残さず、問い合わせ用IDで追えるようにします。

ここで扱ったGraphQL例は、掲載コードをローカルで動かすための最小契約です。実APIのTLS、認証連携、同時実行、DB整合性、プロキシ、ストリーム、性能は別途検証が必要です。GraphQL入門やマイクロサービスの設計を、実際に選んだ方式と運用規模に応じて深めてください。

よくある質問(FAQ)