はじめに
「必要なデータだけを、1回のリクエストで取得したい」
REST APIでは、複数のエンドポイントを叩いて必要なデータを集めたり、不要なデータまで取得してしまうことがあります。GraphQLはこれらの問題を解決する、Facebookが開発したクエリ言語です。
この記事では、GraphQLの基礎から実践的な実装まで、詳しく解説します。
ただ 実務で重要なのは
GraphQLを使えることより
GraphQLを導入すべきか どこで詰まるか を理解することです。
GraphQLは万能ではなく
次のトレードオフがあります。
- 取得形が自由になる代わりに サーバー側の防御と観測が必須
- フロントは便利になるが N+1や権限制御の設計が難しくなる
- キャッシュ戦略がRESTより難しいケースがある
本記事は
RESTとの差分だけでなく
運用で困りがちなポイントも含めて整理します。
先に結論 GraphQLが向くケース 向かないケース
向く
- 画面要件が頻繁に変わる BFF的なAPIが欲しい
- 複数リソースの組み合わせが多い
- 型を中心にクライアントとサーバーを同期したい
向かない
- 単純なCRUDのみで変化が少ない
- キャッシュやCDNで軽く配りたいだけ
- クエリの自由度を許容できない 要件が厳しすぎる
最初に押さえる 実務の落とし穴
N+1問題
GraphQLはフィールドごとにresolverが動くため
素直に書くとDBアクセスが爆発します。
DataLoaderやバッチ取得が重要になります。
認可
Queryが自由度を持つので
どのフィールドを誰が読めるか を設計しておかないと
情報漏洩が起きます。
コスト制御
深いネストや大量フィールドで
高コストクエリを投げられる可能性があります。
深さ制限 複雑度制限 タイムアウト を最初から設計します。
GraphQLとは
REST APIとの比較
| 特徴 | REST API | GraphQL |
|---|---|---|
| エンドポイント | 複数(/users, /posts等) | 単一(/graphql) |
| データ取得 | サーバーが決めた固定形式 | クライアントが必要なフィールドを指定 |
| オーバーフェッチ | 発生しやすい | 発生しない |
| アンダーフェッチ | 複数リクエスト必要 | 1リクエストで解決 |
| 型システム | オプション | 必須(スキーマ定義) |
具体例
# REST APIの場合
GET /users/1 → ユーザー情報
GET /users/1/posts → ユーザーの投稿一覧
GET /posts/1/comments → 投稿のコメント一覧
# 合計3回のリクエストが必要
# GraphQLの場合(1回のリクエスト)
query {
user(id: "1") {
name
email
posts {
title
comments {
content
author { name }
}
}
}
}
GraphQLの基本
スキーマ定義
# schema.graphql
# スカラー型
# String, Int, Float, Boolean, ID が組み込み型
# カスタムスカラー
scalar DateTime
# Enum
enum UserRole {
ADMIN
EDITOR
VIEWER
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# オブジェクト型
type User {
id: ID!
email: String!
name: String!
role: UserRole!
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
status: PostStatus!
author: User!
comments: [Comment!]!
tags: [Tag!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: User!
post: Post!
createdAt: DateTime!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
# 入力型(Mutationで使用)
input CreateUserInput {
email: String!
name: String!
password: String!
role: UserRole = VIEWER
}
input UpdateUserInput {
email: String
name: String
role: UserRole
}
input CreatePostInput {
title: String!
content: String!
status: PostStatus = DRAFT
tagIds: [ID!]
}
# ページネーション用
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PostEdge {
cursor: String!
node: Post!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
# クエリ(読み取り)
type Query {
# 単一取得
user(id: ID!): User
post(id: ID!): Post
# 一覧取得
users(role: UserRole): [User!]!
posts(
status: PostStatus
authorId: ID
first: Int
after: String
): PostConnection!
# 検索
searchPosts(query: String!): [Post!]!
# 現在のユーザー
me: User
}
# ミューテーション(書き込み)
type Mutation {
# ユーザー
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
# 投稿
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: CreatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
# コメント
addComment(postId: ID!, content: String!): Comment!
deleteComment(id: ID!): Boolean!
# 認証
login(email: String!, password: String!): AuthPayload!
logout: Boolean!
}
type AuthPayload {
token: String!
user: User!
}
# サブスクリプション(リアルタイム)
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
クエリの書き方
# 基本的なクエリ
query {
user(id: "1") {
id
name
email
}
}
# 変数を使用
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
# 複数のクエリを同時に実行
query GetUserAndPosts($userId: ID!) {
user(id: $userId) {
name
email
}
posts(authorId: $userId, first: 10) {
edges {
node {
title
createdAt
}
}
}
}
# エイリアス
query {
admin: user(id: "1") {
name
}
guest: user(id: "2") {
name
}
}
# フラグメント(再利用可能なフィールドセット)
fragment UserFields on User {
id
name
email
role
}
query {
user(id: "1") {
...UserFields
posts {
title
}
}
}
# ディレクティブ
query GetUser($id: ID!, $includeEmail: Boolean!, $skipPosts: Boolean!) {
user(id: $id) {
name
email @include(if: $includeEmail)
posts @skip(if: $skipPosts) {
title
}
}
}
ミューテーションの書き方
# ユーザー作成
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
# 変数
{
"input": {
"email": "user@example.com",
"name": "田中太郎",
"password": "password123"
}
}
# 投稿の作成と公開
mutation CreateAndPublishPost($input: CreatePostInput!) {
post: createPost(input: $input) {
id
title
status
}
}
Apollo Serverでの実装
プロジェクトのセットアップ
mkdir graphql-server
cd graphql-server
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node ts-node nodemon
// package.json
{
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
基本的なサーバー実装
// src/index.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
// 型定義
const typeDefs = `#graphql
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(email: String!, name: String!): User!
createPost(title: String!, content: String!, authorId: ID!): Post!
}
`;
// サンプルデータ
const users = [
{ id: '1', email: 'alice@example.com', name: 'Alice' },
{ id: '2', email: 'bob@example.com', name: 'Bob' },
];
const posts = [
{ id: '1', title: 'GraphQL入門', content: 'GraphQLは...', authorId: '1' },
{ id: '2', title: 'TypeScript Tips', content: 'TypeScriptでは...', authorId: '1' },
{ id: '3', title: 'React Hooks', content: 'useStateは...', authorId: '2' },
];
// リゾルバー
const resolvers = {
Query: {
users: () => users,
user: (_: unknown, { id }: { id: string }) =>
users.find(user => user.id === id),
posts: () => posts,
post: (_: unknown, { id }: { id: string }) =>
posts.find(post => post.id === id),
},
Mutation: {
createUser: (_: unknown, { email, name }: { email: string; name: string }) => {
const newUser = {
id: String(users.length + 1),
email,
name,
};
users.push(newUser);
return newUser;
},
createPost: (_: unknown, args: { title: string; content: string; authorId: string }) => {
const newPost = {
id: String(posts.length + 1),
title: args.title,
content: args.content,
authorId: args.authorId,
};
posts.push(newPost);
return newPost;
},
},
// フィールドリゾルバー
User: {
posts: (parent: { id: string }) =>
posts.filter(post => post.authorId === parent.id),
},
Post: {
author: (parent: { authorId: string }) =>
users.find(user => user.id === parent.authorId),
},
};
// サーバー起動
const server = new ApolloServer({
typeDefs,
resolvers,
});
startStandaloneServer(server, {
listen: { port: 4000 },
}).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
実践的な構成
src/
├── index.ts
├── schema/
│ ├── index.ts
│ ├── typeDefs/
│ │ ├── index.ts
│ │ ├── user.ts
│ │ ├── post.ts
│ │ └── comment.ts
│ └── resolvers/
│ ├── index.ts
│ ├── user.ts
│ ├── post.ts
│ └── comment.ts
├── datasources/
│ ├── index.ts
│ ├── UserDataSource.ts
│ └── PostDataSource.ts
├── models/
│ ├── User.ts
│ ├── Post.ts
│ └── Comment.ts
├── utils/
│ ├── auth.ts
│ └── errors.ts
└── context.ts
// src/schema/typeDefs/user.ts
export const userTypeDefs = `#graphql
type User {
id: ID!
email: String!
name: String!
role: UserRole!
posts: [Post!]!
createdAt: DateTime!
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
input CreateUserInput {
email: String!
name: String!
password: String!
role: UserRole
}
input UpdateUserInput {
email: String
name: String
role: UserRole
}
extend type Query {
users(role: UserRole): [User!]!
user(id: ID!): User
me: User
}
extend type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
login(email: String!, password: String!): AuthPayload!
}
type AuthPayload {
token: String!
user: User!
}
`;
// src/schema/resolvers/user.ts
import { GraphQLError } from 'graphql';
import { Context } from '../../context';
export const userResolvers = {
Query: {
users: async (_: unknown, args: { role?: string }, context: Context) => {
return context.dataSources.users.findAll(args.role);
},
user: async (_: unknown, { id }: { id: string }, context: Context) => {
return context.dataSources.users.findById(id);
},
me: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return context.dataSources.users.findById(context.user.id);
},
},
Mutation: {
createUser: async (
_: unknown,
{ input }: { input: { email: string; name: string; password: string; role?: string } },
context: Context
) => {
const existingUser = await context.dataSources.users.findByEmail(input.email);
if (existingUser) {
throw new GraphQLError('Email already exists', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
return context.dataSources.users.create(input);
},
updateUser: async (
_: unknown,
{ id, input }: { id: string; input: { email?: string; name?: string; role?: string } },
context: Context
) => {
// 認証チェック
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// 権限チェック
if (context.user.id !== id && context.user.role !== 'ADMIN') {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
return context.dataSources.users.update(id, input);
},
login: async (
_: unknown,
{ email, password }: { email: string; password: string },
context: Context
) => {
const user = await context.dataSources.users.authenticate(email, password);
if (!user) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const token = context.dataSources.users.generateToken(user);
return { token, user };
},
},
User: {
posts: async (parent: { id: string }, _: unknown, context: Context) => {
return context.dataSources.posts.findByAuthorId(parent.id);
},
},
};
// src/datasources/UserDataSource.ts
import { PrismaClient, User } from '@prisma/client';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
export class UserDataSource {
private prisma: PrismaClient;
constructor(prisma: PrismaClient) {
this.prisma = prisma;
}
async findAll(role?: string): Promise<User[]> {
return this.prisma.user.findMany({
where: role ? { role } : undefined,
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { id },
});
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { email },
});
}
async create(input: {
email: string;
name: string;
password: string;
role?: string;
}): Promise<User> {
const hashedPassword = await bcrypt.hash(input.password, 10);
return this.prisma.user.create({
data: {
email: input.email,
name: input.name,
password: hashedPassword,
role: input.role || 'VIEWER',
},
});
}
async update(
id: string,
input: { email?: string; name?: string; role?: string }
): Promise<User> {
return this.prisma.user.update({
where: { id },
data: input,
});
}
async authenticate(email: string, password: string): Promise<User | null> {
const user = await this.findByEmail(email);
if (!user) return null;
const valid = await bcrypt.compare(password, user.password);
if (!valid) return null;
return user;
}
generateToken(user: User): string {
return jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
);
}
}
// src/context.ts
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { UserDataSource } from './datasources/UserDataSource';
import { PostDataSource } from './datasources/PostDataSource';
const prisma = new PrismaClient();
export interface Context {
user: { id: string; role: string } | null;
dataSources: {
users: UserDataSource;
posts: PostDataSource;
};
}
export async function createContext({ req }: { req: { headers: { authorization?: string } } }): Promise<Context> {
let user = null;
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
role: string;
};
user = { id: decoded.userId, role: decoded.role };
} catch (error) {
// トークンが無効な場合は無視
}
}
return {
user,
dataSources: {
users: new UserDataSource(prisma),
posts: new PostDataSource(prisma),
},
};
}
クライアント側の実装
Apollo Client(React)
npm install @apollo/client graphql
// src/lib/apollo.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
export const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['status', 'authorId'],
merge(existing, incoming, { args }) {
// ページネーションのマージロジック
if (!args?.after) {
return incoming;
}
return {
...incoming,
edges: [...(existing?.edges || []), ...incoming.edges],
};
},
},
},
},
},
}),
});
// src/App.tsx
import { ApolloProvider } from '@apollo/client';
import { client } from './lib/apollo';
import { UserList } from './components/UserList';
function App() {
return (
<ApolloProvider client={client}>
<UserList />
</ApolloProvider>
);
}
// src/graphql/queries.ts
import { gql } from '@apollo/client';
export const GET_USERS = gql`
query GetUsers($role: UserRole) {
users(role: $role) {
id
name
email
role
}
}
`;
export const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
role
posts {
id
title
status
createdAt
}
}
}
`;
export const GET_POSTS = gql`
query GetPosts($first: Int, $after: String, $status: PostStatus) {
posts(first: $first, after: $after, status: $status) {
edges {
cursor
node {
id
title
content
status
author {
id
name
}
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
`;
// src/graphql/mutations.ts
import { gql } from '@apollo/client';
export const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
role
}
}
`;
export const UPDATE_USER = gql`
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
role
}
}
`;
export const LOGIN = gql`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
user {
id
name
email
role
}
}
}
`;
// src/components/UserList.tsx
import { useQuery } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';
interface User {
id: string;
name: string;
email: string;
role: string;
}
export function UserList() {
const { loading, error, data, refetch } = useQuery<{ users: User[] }>(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>ユーザー一覧</h1>
<button onClick={() => refetch()}>更新</button>
<ul>
{data?.users.map(user => (
<li key={user.id}>
{user.name} ({user.email}) - {user.role}
</li>
))}
</ul>
</div>
);
}
// src/components/CreateUserForm.tsx
import { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_USER } from '../graphql/mutations';
import { GET_USERS } from '../graphql/queries';
export function CreateUserForm() {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [createUser, { loading, error }] = useMutation(CREATE_USER, {
// キャッシュの更新
update(cache, { data: { createUser } }) {
const existingUsers = cache.readQuery<{ users: any[] }>({ query: GET_USERS });
if (existingUsers) {
cache.writeQuery({
query: GET_USERS,
data: { users: [...existingUsers.users, createUser] },
});
}
},
// または refetchQueries を使用
// refetchQueries: [{ query: GET_USERS }],
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createUser({
variables: {
input: { email, name, password },
},
});
setEmail('');
setName('');
setPassword('');
} catch (err) {
console.error(err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="メールアドレス"
required
/>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="名前"
required
/>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="パスワード"
required
/>
<button type="submit" disabled={loading}>
{loading ? '作成中...' : 'ユーザー作成'}
</button>
{error && <p className="error">{error.message}</p>}
</form>
);
}
N+1問題の解決
DataLoaderの使用
npm install dataloader
// src/loaders/index.ts
import DataLoader from 'dataloader';
import { PrismaClient, User, Post } from '@prisma/client';
export function createLoaders(prisma: PrismaClient) {
return {
userLoader: new DataLoader<string, User | null>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: ids as string[] } },
});
const userMap = new Map(users.map(user => [user.id, user]));
return ids.map(id => userMap.get(id) || null);
}),
postsByAuthorLoader: new DataLoader<string, Post[]>(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: authorIds as string[] } },
});
const postsByAuthor = new Map<string, Post[]>();
posts.forEach(post => {
const existing = postsByAuthor.get(post.authorId) || [];
postsByAuthor.set(post.authorId, [...existing, post]);
});
return authorIds.map(id => postsByAuthor.get(id) || []);
}),
};
}
// リゾルバーでの使用
const resolvers = {
Post: {
author: (parent: { authorId: string }, _: unknown, context: Context) => {
return context.loaders.userLoader.load(parent.authorId);
},
},
User: {
posts: (parent: { id: string }, _: unknown, context: Context) => {
return context.loaders.postsByAuthorLoader.load(parent.id);
},
},
};
ベストプラクティス
エラーハンドリング
import { GraphQLError } from 'graphql';
// カスタムエラー
export class AuthenticationError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: 'UNAUTHENTICATED' },
});
}
}
export class ForbiddenError extends GraphQLError {
constructor(message: string) {
super(message, {
extensions: { code: 'FORBIDDEN' },
});
}
}
export class ValidationError extends GraphQLError {
constructor(message: string, field?: string) {
super(message, {
extensions: { code: 'BAD_USER_INPUT', field },
});
}
}
ページネーション
# Cursor-based pagination(推奨)
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
バリデーション
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
name: z.string().min(2, '名前は2文字以上必要です').max(50),
password: z.string().min(8, 'パスワードは8文字以上必要です'),
});
const resolvers = {
Mutation: {
createUser: async (_: unknown, { input }: any, context: Context) => {
const validated = CreateUserSchema.parse(input);
return context.dataSources.users.create(validated);
},
},
};
まとめ
| 概念 | 説明 |
|---|---|
| スキーマ | APIの型定義(Query, Mutation, Type) |
| リゾルバー | データ取得のロジック |
| Query | 読み取り操作 |
| Mutation | 書き込み操作 |
| Subscription | リアルタイム更新 |
| DataLoader | N+1問題の解決 |
GraphQLを活用して、効率的で型安全なAPIを構築しましょう!