GraphQL + Apollo実践ガイド2026
GraphQLはRESTの課題を解決する次世代のAPIクエリ言語として、多くの企業で採用されています。本記事では、ApolloというGraphQLのエコシステムを使った実践的な開発方法を解説します。
GraphQLとは
GraphQLはFacebookが開発したAPIのためのクエリ言語およびランタイムです。
RESTとの比較
RESTの課題:
- Over-fetching: 不要なデータまで取得
- Under-fetching: 複数のエンドポイントへのリクエストが必要
- バージョニングの複雑さ
- ドキュメントの保守
GraphQLの利点:
- 必要なデータだけを正確に取得
- 1回のリクエストで複数のリソースを取得
- 強力な型システム
- 自己文書化
- リアルタイム通信(Subscription)
基本概念
# スキーマ定義
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
user(id: ID!): User
posts: [Post!]!
}
type Mutation {
createPost(title: String!, content: String!): Post!
}
type Subscription {
postAdded: Post!
}
# クエリ例
query GetUser {
user(id: "1") {
name
email
posts {
title
}
}
}
# ミューテーション例
mutation CreatePost {
createPost(title: "Hello", content: "World") {
id
title
}
}
# サブスクリプション例
subscription OnPostAdded {
postAdded {
id
title
author {
name
}
}
}
Apollo Server セットアップ
Apollo Serverは最も人気のあるGraphQLサーバー実装です。
インストール
npm install @apollo/server graphql
基本的なサーバー
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
// スキーマ定義
const typeDefs = `#graphql
type Book {
id: ID!
title: String!
author: String!
}
type Query {
books: [Book!]!
book(id: ID!): Book
}
type Mutation {
addBook(title: String!, author: String!): Book!
}
`;
// データソース(実際はデータベースを使用)
const books = [
{ id: '1', title: 'The Awakening', author: 'Kate Chopin' },
{ id: '2', title: 'City of Glass', author: 'Paul Auster' },
];
// リゾルバー
const resolvers = {
Query: {
books: () => books,
book: (parent, args) => {
return books.find(book => book.id === args.id);
},
},
Mutation: {
addBook: (parent, args) => {
const newBook = {
id: String(books.length + 1),
title: args.title,
author: args.author,
};
books.push(newBook);
return newBook;
},
},
};
// サーバー作成
const server = new ApolloServer({
typeDefs,
resolvers,
});
// サーバー起動
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`🚀 Server ready at ${url}`);
Expressとの統合
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import cors from 'cors';
import bodyParser from 'body-parser';
const app = express();
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
app.use(
'/graphql',
cors<cors.CorsRequest>(),
bodyParser.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
// コンテキストにユーザー情報などを追加
token: req.headers.authorization,
}),
}),
);
app.listen(4000, () => {
console.log('Server running on http://localhost:4000/graphql');
});
スキーマ設計
効果的なGraphQLスキーマを設計するためのベストプラクティスです。
型定義
# スカラー型
scalar Date
scalar Upload
# 列挙型
enum Role {
ADMIN
USER
GUEST
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
# オブジェクト型
type User {
id: ID!
username: String!
email: String!
role: Role!
createdAt: Date!
posts(status: PostStatus): [Post!]!
profile: UserProfile
}
type UserProfile {
bio: String
avatar: String
website: String
}
type Post {
id: ID!
title: String!
content: String!
status: PostStatus!
author: User!
tags: [Tag!]!
createdAt: Date!
updatedAt: Date!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
# 入力型
input CreatePostInput {
title: String!
content: String!
tagIds: [ID!]!
}
input UpdatePostInput {
title: String
content: String
status: PostStatus
tagIds: [ID!]
}
input PostFilterInput {
status: PostStatus
authorId: ID
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(limit: Int, offset: Int): [User!]!
posts(
filter: PostFilterInput
first: Int
after: String
last: Int
before: String
): PostConnection!
# 検索
searchPosts(query: String!): [Post!]!
}
# ミューテーション
type Mutation {
# ユーザー操作
register(username: String!, email: String!, password: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
updateProfile(bio: String, avatar: Upload): User!
# 投稿操作
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
}
# サブスクリプション
type Subscription {
postAdded(authorId: ID): Post!
postUpdated(id: ID!): Post!
postDeleted: ID!
}
# 認証ペイロード
type AuthPayload {
token: String!
user: User!
}
リゾルバー実装
import { GraphQLError } from 'graphql';
interface Context {
userId?: string;
db: Database; // あなたのDB接続
}
const resolvers = {
// カスタムスカラー
Date: {
parseValue(value: number) {
return new Date(value);
},
serialize(value: Date) {
return value.getTime();
},
},
// Query リゾルバー
Query: {
user: async (parent, { id }, context: Context) => {
return await context.db.users.findById(id);
},
posts: async (parent, { filter, first, after }, context: Context) => {
const limit = first || 10;
const offset = after ? parseInt(after) : 0;
const posts = await context.db.posts.find({
...filter,
limit,
offset,
});
const totalCount = await context.db.posts.count(filter);
return {
edges: posts.map((post, index) => ({
cursor: String(offset + index),
node: post,
})),
pageInfo: {
hasNextPage: offset + limit < totalCount,
hasPreviousPage: offset > 0,
startCursor: String(offset),
endCursor: String(offset + posts.length - 1),
},
totalCount,
};
},
searchPosts: async (parent, { query }, context: Context) => {
return await context.db.posts.search(query);
},
},
// Mutation リゾルバー
Mutation: {
createPost: async (parent, { input }, context: Context) => {
if (!context.userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
const post = await context.db.posts.create({
...input,
authorId: context.userId,
status: 'DRAFT',
createdAt: new Date(),
});
// サブスクリプションに通知
pubsub.publish('POST_ADDED', { postAdded: post });
return post;
},
updatePost: async (parent, { id, input }, context: Context) => {
const post = await context.db.posts.findById(id);
if (!post) {
throw new GraphQLError('Post not found', {
extensions: { code: 'NOT_FOUND' },
});
}
if (post.authorId !== context.userId) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
const updated = await context.db.posts.update(id, {
...input,
updatedAt: new Date(),
});
pubsub.publish('POST_UPDATED', { postUpdated: updated });
return updated;
},
deletePost: async (parent, { id }, context: Context) => {
const post = await context.db.posts.findById(id);
if (post.authorId !== context.userId) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
await context.db.posts.delete(id);
pubsub.publish('POST_DELETED', { postDeleted: id });
return true;
},
},
// フィールドリゾルバー
User: {
posts: async (parent, { status }, context: Context) => {
return await context.db.posts.find({
authorId: parent.id,
...(status && { status }),
});
},
profile: async (parent, args, context: Context) => {
return await context.db.userProfiles.findByUserId(parent.id);
},
},
Post: {
author: async (parent, args, context: Context) => {
// DataLoaderを使うとN+1問題を解決できる
return context.loaders.user.load(parent.authorId);
},
tags: async (parent, args, context: Context) => {
return await context.db.tags.findByPostId(parent.id);
},
},
// Subscription リゾルバー
Subscription: {
postAdded: {
subscribe: (parent, { authorId }) => {
if (authorId) {
return pubsub.asyncIterator([`POST_ADDED_${authorId}`]);
}
return pubsub.asyncIterator(['POST_ADDED']);
},
},
postUpdated: {
subscribe: (parent, { id }) => {
return pubsub.asyncIterator([`POST_UPDATED_${id}`]);
},
},
},
};
DataLoaderでN+1問題を解決
import DataLoader from 'dataloader';
function createLoaders(db: Database) {
return {
user: new DataLoader(async (ids: readonly string[]) => {
const users = await db.users.findByIds(ids);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
}),
posts: new DataLoader(async (userIds: readonly string[]) => {
const posts = await db.posts.findByAuthorIds(userIds);
const postsMap = new Map<string, Post[]>();
posts.forEach(post => {
if (!postsMap.has(post.authorId)) {
postsMap.set(post.authorId, []);
}
postsMap.get(post.authorId)!.push(post);
});
return userIds.map(id => postsMap.get(id) || []);
}),
};
}
// コンテキストに追加
expressMiddleware(server, {
context: async ({ req }) => ({
userId: getUserIdFromToken(req.headers.authorization),
db,
loaders: createLoaders(db),
}),
});
Apollo Client
フロントエンドでGraphQLを使うためのライブラリです。
セットアップ(React)
npm install @apollo/client graphql
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache(),
headers: {
authorization: localStorage.getItem('token') || '',
},
});
function App() {
return (
<ApolloProvider client={client}>
<YourApp />
</ApolloProvider>
);
}
Query
import { gql, useQuery } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts($filter: PostFilterInput) {
posts(filter: $filter, first: 10) {
edges {
node {
id
title
author {
username
}
createdAt
}
}
pageInfo {
hasNextPage
}
}
}
`;
function PostList() {
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: { filter: { status: 'PUBLISHED' } },
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
const posts = data.posts.edges.map(edge => edge.node);
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.username}</p>
</article>
))}
{data.posts.pageInfo.hasNextPage && (
<button onClick={() => fetchMore({
variables: {
after: data.posts.edges[data.posts.edges.length - 1].cursor,
},
})}>
Load More
</button>
)}
</div>
);
}
Mutation
import { gql, useMutation } from '@apollo/client';
const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
}
}
`;
function CreatePostForm() {
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
// キャッシュを更新
update(cache, { data: { createPost } }) {
cache.modify({
fields: {
posts(existingPosts = { edges: [] }) {
const newPostRef = cache.writeFragment({
data: createPost,
fragment: gql`
fragment NewPost on Post {
id
title
content
}
`
});
return {
...existingPosts,
edges: [{ node: newPostRef }, ...existingPosts.edges],
};
}
}
});
},
// または refetchQueries を使用
// refetchQueries: [{ query: GET_POSTS }],
});
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
await createPost({
variables: {
input: {
title: formData.get('title'),
content: formData.get('content'),
tagIds: [],
},
},
});
alert('Post created!');
} catch (err) {
console.error(err);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}
Subscription
import { gql, useSubscription } from '@apollo/client';
const POST_ADDED_SUBSCRIPTION = gql`
subscription OnPostAdded {
postAdded {
id
title
author {
username
}
}
}
`;
function RealtimePosts() {
const { data, loading } = useSubscription(POST_ADDED_SUBSCRIPTION);
useEffect(() => {
if (data) {
toast.info(`New post: ${data.postAdded.title}`);
}
}, [data]);
return null; // または通知UIを表示
}
WebSocket設定:
import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
const httpLink = new HttpLink({
uri: 'http://localhost:4000/graphql'
});
const wsLink = new GraphQLWsLink(createClient({
url: 'ws://localhost:4000/graphql',
}));
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
キャッシュ戦略
Apollo Clientの強力なキャッシュ機能を活用しましょう。
キャッシュ設定
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// ページネーションのマージ
keyArgs: ['filter'],
merge(existing = { edges: [] }, incoming) {
return {
...incoming,
edges: [...existing.edges, ...incoming.edges],
};
},
},
},
},
Post: {
fields: {
// フィールド単位のキャッシュポリシー
isLiked: {
read(cached, { variables }) {
// カスタム読み取りロジック
return cached ?? false;
},
},
},
},
},
});
楽観的UI更新
const [deletePost] = useMutation(DELETE_POST, {
optimisticResponse: {
deletePost: true,
},
update(cache, { data }) {
cache.modify({
fields: {
posts(existingPosts, { readField }) {
return {
...existingPosts,
edges: existingPosts.edges.filter(
edge => readField('id', edge.node) !== postId
),
};
},
},
});
},
});
GraphQL Code Generator
TypeScriptの型を自動生成します。
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
codegen.yml:
schema: http://localhost:4000/graphql
documents: 'src/**/*.graphql'
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
config:
withHooks: true
自動生成:
npx graphql-codegen
使用例:
import { useGetPostsQuery, useCreatePostMutation } from './generated/graphql';
function Posts() {
const { data, loading } = useGetPostsQuery({
variables: { filter: { status: 'PUBLISHED' } }
});
const [createPost] = useCreatePostMutation();
// 完全な型安全性
}
まとめ
GraphQL + Apolloは強力で柔軟なAPI開発を可能にします。
重要なポイント:
- スキーマファーストで型安全な開発
- DataLoaderでN+1問題を解決
- Apollo Clientの強力なキャッシュ機能
- Subscriptionでリアルタイム通信
- Code Generatorで型を自動生成
RESTに代わる次世代のAPI技術として、GraphQLはますます重要になっています。本記事を参考に、効率的なAPI開発を実践してください。