GraphQLのスキーマは、クライアントとサーバーが共有する型の契約です。一度公開したフィールドはクライアントのクエリに書き込まれるため、後から型や名前を変えると既存のアプリが壊れます。設計の良し悪しは、公開後に「足すだけで進化できるか」で決まります。
この記事では、GraphQL仕様の現行版(September 2025、2025年9月3日公開)とリファレンス実装graphql-js 17.0.2を前提に、null許容・命名・カスタムスカラー・ミューテーションの入出力・ページネーション・非推奨化の決め方を、SDL(Schema Definition Language)の例と実行結果で示します。GraphQL自体の仕組みやRESTとの違いはGraphQLとは?REST APIとの違い・メリット・デメリットを先に読むと前提がそろいます。
まとめ:GraphQLスキーマ設計で先に決める要点
- 出力フィールドは「null許容が既定」。non-null(
!)は、失敗時に親ごとnullにしてよいフィールドだけに付ける - 命名は型・enumがPascalCase、フィールド・引数がcamelCase、enum値がSCREAMING_SNAKE_CASE。graphql.orgの命名ガイドもこの表記を挙げている
- 金額は組み込み型に無いので
Decimalなどのカスタムスカラーを定義し、@specifiedByで表現形式の仕様URLを示す。Intは32ビット符号付き整数まで - ミューテーションは「専用Input型を1つ受け取り、Payload型を返す」。業務エラーはPayload内の型付きフィールドで返す
- 件数が増えうる一覧は最初からConnection型(
edges・node・cursor・pageInfo)で公開する。後から配列をConnectionへ変えると破壊的変更になる - バージョン番号は切らず、
@deprecatedで移行期間を設けてから削除する。削除・型変更・必須引数の追加はCIでfindBreakingChangesやGraphQL Inspectorで止める
GraphQLスキーマとSDL:設計で決める範囲と仕様の現行版
スキーマは、クライアントが問い合わせられる型・フィールド・引数・操作の一覧です。SDLはそれを書くための言語で、type・input・enum・interface・union・scalar・directiveの定義と、ルート操作のQuery・Mutation・Subscriptionで構成します。
組み込みスカラー5種と仕様September 2025版の追加点
仕様が定める組み込みスカラーはInt・Float・String・Boolean・IDの5つだけです。日付や金額の型はありません。
2021年10月版から約4年ぶりのSeptember 2025版では、変更履歴の主な項目として、OneOf入力オブジェクト(@oneOf)、スキーマ座標(User.nameのように要素を指す表記の標準化)、引数・入力フィールドへの@deprecatedの拡大、Unicode全域への対応が挙がっています。graphql-jsでは@oneOfが16.9.0(2024年6月21日)から正式に使え、16.14.2でも動作します。本記事の検証は最新の17.0.2(2026年7月3日公開)で行いました。
スキーマファーストとコードファーストの選び方
SDLを先に書いてリゾルバーを合わせる方式(スキーマファースト)と、TypeScriptやJavaのコードから型を生成する方式(コードファースト)があります。どちらを採っても、公開する契約はSDLです。コードファーストでも生成したSDLをリポジトリにコミットし、差分をレビュー対象にしておけば、後述する破壊的変更の検知をそのまま使えます。フロントエンドと並行開発するならスキーマファーストのほうが合意点が明確です。仕様を先に固める進め方とモックでの並行開発はスキーマファーストとは?OpenAPI起点の並行開発と破壊的変更検知で詳しく扱っています。
nullとnon-nullの決め方:失敗時にnullが親へ伝播する範囲
GraphQLの出力フィールドは、何も付けなければnull許容です。graphql.orgのSchema Designは、ネットワーク越しのDBや外部サービスの障害、権限による非表示などで値が取れないことがあるため、既定をnull許容にしていると説明しています。
non-nullを付けたフィールドがnullを返すと、エラーはそのフィールドで止まらず、null許容の親まで遡って親ごとnullになります。graphql-js 17.0.2で、Query.userをnull許容、User.idとUser.nameをnon-nullにしたスキーマで、nameだけがnullになる状況を再現しました(応答はlocationsを省いた抜粋)。
# スキーマ: type Query { user: User } type User { id: ID! name: String! email: String }
# name が null のとき
{"errors":[{"message":"Cannot return null for non-nullable field User.name.","path":["user","name"]}],
"data":{"user":null}}
# name を String(null許容)にした場合
{"data":{"user":{"id":"1","name":null,"email":"a@b"}}}
non-nullにした結果、取得できていたidとemailまで失われています。Query.userもnon-nullだった場合は、nullがさらに上へ伝わりdata全体がnullになります。判断基準は次のとおりです。
- non-nullにする:
idのように、取れないならそのオブジェクト自体が成立しないフィールド。ミューテーションの必須入力(引数・Input型のフィールド) - null許容にする:別サービスや別テーブルから取るフィールド、権限で隠すフィールド、将来計算方法が変わりうる集計値
- リスト:
[Order!]!(リストも要素もnullにならない)を基本にし、要素の取得が個別に失敗しうる場合だけ要素をnull許容にする
出力側でnull許容からnon-nullへ変えるのはクライアントを壊しませんが、逆方向は破壊的変更です。迷ったらnull許容で公開し、運用で常に値があると確認できてからnon-nullにします。
命名規則:型・フィールド・enum値の表記と避ける名前
仕様は名前の大文字・小文字を規定していませんが、graphql.orgの「Naming Conventions and Design Standards」は次の表記を推奨しています。
| 要素 | 表記 | 例 |
|---|---|---|
| 型・interface・union | PascalCase | User、ProductConnection、MediaItem |
| enum型 | PascalCase | OrderStatus |
| enum値 | SCREAMING_SNAKE_CASE | IN_PROGRESS、COMPLETED |
| フィールド・引数 | camelCase | createdAt、includeArchived |
| ディレクティブ | camelCase | @include、@deprecated |
| 入力型・結果型 | Input・Payloadの接尾辞 | CreateUserInput、CreateUserPayload |
表記より事故につながりやすいのは中身の名前です。DBの列名(usr_nmやis_del)をそのまま出すと、テーブル設計を変えるたびにAPIが引きずられます。User.userNameのように型名をフィールド名で繰り返す必要もありません。ミューテーションはcreateUserのような動詞始まりとuserCreateのような名詞始まりのどちらかに統一します。graphql.orgはどちらも実運用で使われているとしたうえで、1つに決めることを求めています。
カスタムスカラーとenum:金額・日時・状態を型で表す
組み込みのIntは32ビット符号付き整数です。graphql-js 17.0.2で2147483648を渡すと、次のエラーで実行前に拒否されます(locationsを省いた抜粋)。
{"errors":[{"message":"Int cannot represent non 32-bit signed integer value: 2147483648"}]}
大きな整数のIDや、誤差を許さない金額をIntやFloatで表すと、この上限や浮動小数点の丸めに当たります。そのため金額は文字列でやり取りするカスタムスカラーとして定義し、形式を@specifiedByで示します。
"""小数点以下を含む金額。JSONでは "1234.50" のような文字列で送受信する"""
scalar Decimal @specifiedBy(url: "https://example.com/decimal-spec")
"""ISO 8601形式の日時"""
scalar DateTime
enum OrderStatus {
PENDING
SHIPPED
CANCELLED
}
type Order {
id: ID!
total: Decimal!
status: OrderStatus!
orderedAt: DateTime!
}
例のexample.comは仮のURLです。実際には、文字列の書式・桁数・丸めの規則を書いた自社の仕様ページを指定します。@specifiedByのURLはイントロスペクションのspecifiedByURLとして返るため、クライアントのコード生成ツールが型の意味を参照できます。値の検証と変換はサーバー側でGraphQLScalarTypeのparseValue・serializeに実装します。日時や金額など定番のスカラーは、The Guildのgraphql-scalarsのような既製ライブラリを使うと自前実装を減らせます。
状態のように値が決まっている項目は、Stringではなくenumにします。ただし出力に使うenumへ値を追加すると、古いクライアントが未知の値を受け取ります。graphql-jsはこれを「危険な変更(dangerous)」に分類するので、クライアントに未知の値の扱いを用意してもらう前提で追加します。
ミューテーションの入出力:Input型・Payload型・@oneOf
ミューテーションは、引数を1つのInput型にまとめ、結果をPayload型で返す形にそろえます。エンティティを直接返すと、後から業務エラーや関連オブジェクトを足す場所がありません。
input CancelOrderInput {
orderId: ID!
reason: String @deprecated(reason: "reasonCode を使う")
reasonCode: String
}
type UserError {
field: [String!]
message: String!
code: String!
}
type CancelOrderPayload {
order: Order
errors: [UserError!]!
}
type Mutation {
cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}
エラーの返し方は2系統に分けます。DB停止や認証基盤の障害のような想定外の失敗はレスポンス最上位のerrors配列に任せ、「キャンセル期限切れ」「在庫不足」のように画面で案内する業務エラーはPayloadのerrorsフィールドに型付きで入れます。graphql.orgのError Handlingも、前者をtop-level errors、後者をerrors-as-dataとして使い分けるよう説明しています。
@oneOfによる排他的な入力指定
「注文IDかメールアドレスのどちらか1つで検索する」のような入力は、以前はすべてnull許容のフィールドを並べ、どれか1つだけ指定されたかをリゾルバーで検査していました。September 2025版の@oneOfを付けると、この制約をスキーマが検証します。
input OrderLookup @oneOf {
id: ID
customerEmail: String
}
# 2つ指定した場合の graphql-js 17.0.2 の応答(locationsを省いた抜粋)
{"errors":[{"message":"Within OneOf Input Object type \"OrderLookup\", exactly one field must be specified, and the value for that field must be non-null."}]}
クライアントやゲートウェイが@oneOfに未対応だと、この制約を知らないまま両方を送ってくることがあります。導入前に、利用中のサーバー・コード生成・ゲートウェイの対応版を確認してください。
一覧フィールドのページネーション:公開時点からのConnection型
orders(limit: Int, offset: Int): [Order!]!のように素の配列でも引数でページ分割はできます。ただし配列には「次のページがあるか」「どこまで読んだか」を返す場所がなく、後から足そうとしてOrderConnectionに変えると戻り値の型が変わり、既存のクエリがすべて壊れます。件数が増えうる一覧は、公開時点でConnection型にしておくのが安全です。
type Query {
orders(first: Int, after: String): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge {
cursor: String!
node: Order!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
この形はRelayのCursor Connections仕様に沿ったもので、仕様はPageInfoの4フィールドを必須とし、結果が空のときはstartCursorとendCursorをnullにできると定めています。クライアント側のページ結合は別途設定が要ります。Apollo ClientならrelayStylePagination()をフィールドポリシーに指定して初めて、取得済みページと次のページがキャッシュ上で連結されます。カーソルの中身をどう作るか(キーセットの値をエンコードする、総件数を返すかどうか)はDB側の設計になるため、ページネーション設計とは?オフセットとカーソルの選び分けと実装を参照してください。管理画面のように「何ページ目」へ飛ぶ必要がある一覧だけは、offset・limit引数を併設する判断もあります。
バージョンを切らずに進化させる:@deprecatedと破壊的変更のCI検出
graphql.orgは、GraphQLもRESTのようにバージョンを切れるとしつつ、スキーマを継続的に進化させてバージョンを避ける立場を取っています。クライアントは必要なフィールドだけを要求するので、フィールドの追加は既存のクエリに影響しません。進化の手順は「新しいフィールドを追加→古いフィールドに@deprecatedを付ける→利用がなくなったのを確認して削除」です。
非推奨にするときは、代わりに使うフィールドと削除予定時期をreasonに書きます。September 2025版から、出力フィールドとenum値に加えて、引数と入力フィールドにも@deprecatedを付けられるようになりました。前章のCancelOrderInput.reasonは、graphql-js 17.0.2のイントロスペクションでinputFields(includeDeprecated: true)を指定するとisDeprecated: trueとして返ることを確認しています。引数の既定はfalseなので、指定しないと非推奨の入力フィールドは一覧に出てきません。
プルリクエスト単位の破壊的変更チェック
graphql-jsのfindBreakingChanges・findDangerousChanges(17.0.2では両方をまとめたfindSchemaChangesもある)は、新旧2つのスキーマを比べて差分を分類します。フィールド削除・出力のDecimal!からDecimalへの変更・必須引数の追加・enum値の追加を混ぜて比較した結果が次です。
--- breaking
REQUIRED_ARG_ADDED | A required argument Query.order(locale:) was added.
FIELD_REMOVED | Field Order.note was removed.
FIELD_CHANGED_KIND | Field Order.total changed type from Decimal! to Decimal.
--- dangerous
VALUE_ADDED_TO_ENUM | Enum value OrderStatus.RETURNED was added.
CIでは、mainブランチのSDLとプルリクエストのSDLを比べ、breakingが1件でもあれば失敗させます。次のスクリプトはnpm i graphqlで入れたgraphql-jsを使うES Modules(.mjs)で、mainのSDLをschema.main.graphql、変更後をschema.graphqlとして同じディレクトリに置き、node check-schema.mjsで実行します。フィールドを1つ削除した差分では、終了コード1で止まることを確認しました。
import { readFileSync } from 'node:fs';
import { buildSchema, findBreakingChanges } from 'graphql';
const before = buildSchema(readFileSync('schema.main.graphql', 'utf8'));
const after = buildSchema(readFileSync('schema.graphql', 'utf8'));
const breaking = findBreakingChanges(before, after);
for (const c of breaking) console.error(c.type, c.description);
process.exit(breaking.length > 0 ? 1 : 0);
既製のCLIならGraphQL Inspector(@graphql-inspector/cli、2026年10月時点で7.0.0)のdiffコマンドが同じ判定を行い、GitHub Actionsへ組み込めます。「誰も使っていない非推奨フィールドだけ削除を許す」運用まで行うなら、実際のクエリの利用状況を集めて差分判定に渡す仕組みが必要です。GraphQL Inspectorは--rule considerUsage --onUsageで利用状況を問い合わせる処理を差し込めます。GraphQL HiveやApollo GraphOS(rover subgraph check)は、利用状況の収集と照合までをまとめて提供するスキーマレジストリです。クライアント側の型をSDLから生成しておくと、削除の影響がフロントエンドのビルドエラーとして見えるようになります。生成の設定はGraphQL Code Generatorとは?client presetの設定とv7の変更点で扱っています。
スキーマスティッチングとフェデレーション:複数スキーマを1つにする方式
サービスが増えると、複数のGraphQLスキーマを1つのエンドポイントにまとめたくなります。代表的な方式は2つです。
- スキーマスティッチング:The Guildの
@graphql-tools/stitch(2026年10月時点で10.3.1)が、各サービスのスキーマを読み込んでゲートウェイ用のスキーマを組み立て、リクエストを下位サービスへ委譲します。型の結合規則をゲートウェイ側のコードで書くのが基本です - フェデレーション:各サービス(サブグラフ)が
@keyなどのディレクティブで「どの型をどのキーで結合するか」を自分のスキーマに宣言します。合成(composition)でサブグラフをスーパーグラフスキーマにまとめ、ルーターはそのスーパーグラフに含まれる振り分け情報をもとにクエリを各サブグラフへ送ります。Apollo Federationが2019年に導入し、現在はHive RouterなどApollo以外の実装もあります
The Guildのドキュメントは、GraphQL Tools v7以降のスティッチングは自動のクエリプランや型のマージを備え、Apollo Federationとかなり近いと説明しています。スティッチングにも、サービス側のスキーマに@keyなどを書く「ディレクティブ方式の型マージ」があるため、結合規則の置き場所だけでは両者を分けられません。実務での目安は運用体制です。ゲートウェイを1チームで管理し、既存のGraphQLサービスをコードで柔軟に束ねたいならスティッチング、サービスごとに別チームが独立してスキーマを公開し、合成のチェックをCIやレジストリで回したいならフェデレーションが向きます。フェデレーションの合成とルーターの動き、採用を見送る条件はGraphQLフェデレーションとは?サブグラフ合成とルーターの実装・採用判断で解説しています。
どちらの方式でも、名前の衝突と非推奨化の扱いは全サービスで共通のルールにしておく必要があります。前述の命名規則と@deprecatedの運用は、統合前に各チームでそろえてください。
採用すべきでない設計:DBの写し・汎用JSON・無制限のネスト
次の3つは、公開後に直すと破壊的変更になるため、レビューの段階で止めます。
テーブル構造をそのまま写した型の外部公開
DBから自動生成したスキーマをそのまま外部公開すると、中間テーブルやis_delのような内部フラグまでAPIの契約になります。その状態でテーブルを分割・統合すると、変更がそのまま公開スキーマの削除や型変更として現れ、クライアントが壊れます。graphql.orgの命名ガイドも「Don’t mirror database structure」の項で、スキーマはテーブルではなく業務ドメインを反映すべきだとしています。社内の管理画面に限るなら自動生成でも構いませんが、外部や複数チームに出すスキーマは画面やユースケースから逆算して設計します。
JSONスカラーによる構造の隠蔽
scalar JSONで任意のオブジェクトを返すと、そのJSONの中身のプロパティについては、型の検証・コード生成・破壊的変更の検知が効かなくなります(フィールド自体の削除や型変更は検知できます)。中身の形が決まっているなら型を定義し、本当に自由形式のメタデータだけをJSONで返します。
クエリの深さと取得件数の無制限な公開
user { friends { friends { friends ... } } }のような循環する関係を上限なしで公開すると、1回のクエリでDBに大量の問い合わせが発生します。一覧にはfirstの上限値を設け、公開APIではクエリの深さ・複雑度の制限を入れます。graphql-jsなら検証ルールとして差し込む@graphile/depth-limit(0.3.1)のようなパッケージがあります。上限値は、実際の画面が発行するクエリの最大の深さと件数を計測し、それに余裕を足して決めます。ネストした一覧の取得で発生するN+1の検出と対策はN+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策にまとめています。
よくある質問
GraphQLのスキーマとは何ですか?
クライアントが取得・更新できるデータの型と操作を定義した契約です。サーバーはスキーマに沿わないクエリを実行前に拒否し、クライアントはイントロスペクションでスキーマを取得して補完や型生成に使います。
SDLとは何ですか?
GraphQLのスキーマを記述する言語(Schema Definition Language)です。type User { id: ID! }のように型とフィールドを書きます。コードファーストで実装した場合も、生成されたSDLを契約として共有します。
GraphQLにDecimal型はありますか?
ありません。組み込みスカラーはInt・Float・String・Boolean・IDの5つです。金額などはカスタムスカラーとして定義し、文字列でやり取りして、形式を@specifiedByで示します。
GraphQLのスキーマはバージョン管理すべきですか?
URLやスキーマに版番号を付けるのではなく、フィールドの追加と@deprecatedによる段階的な削除で進化させるのが一般的です。一方で、SDLファイル自体はGitで管理し、差分をCIで検査します。
スキーマスティッチングとフェデレーションはどちらを選ぶべきですか?
ゲートウェイを1チームで管理し、既存サービスをコードで束ねるならスティッチング、サービスごとのチームが独立してスキーマを公開するならフェデレーションが目安です。スティッチングにもサービス側にディレクティブを書く方式があるため、技術的な可否より運用体制で選びます。