GraphQL実装ガイド - Apollo ServerとTypeScriptで型安全なAPI構築

中級 | 50分 で読める | 2025.12.02

公式ドキュメント

今回やること

この記事では、GraphQLのスキーマ、Query、Mutation、Resolver、認証、N+1対策を実装観点で確認します。

GraphQLは柔軟なAPIを作れますが、認可、クエリ制限、N+1対策を入れないと運用リスクが高くなります。

GraphQLの基本概念

GraphQLは、Facebookが開発したAPIのためのクエリ言語です。クライアントが必要なデータを正確に指定でき、オーバーフェッチ・アンダーフェッチを防ぎます。

実践メモ: RESTでは複数エンドポイントに分散するデータを、GraphQLでは1つのクエリで取得できます。

REST API vs GraphQL

REST API(3リクエスト必要):

sequenceDiagram
 participant Client
 participant Server
 Client->>Server: GET /users/1
 Server-->>Client: user data
 Client->>Server: GET /users/1/posts
 Server-->>Client: posts data
 Client->>Server: GET /users/1/followers
 Server-->>Client: followers data

GraphQL(1リクエストで完結):

sequenceDiagram
 participant Client
 participant Server
 Client->>Server: POST /graphql<br/>query { user(id: 1) { name, posts, followers } }
 Server-->>Client: 全データを一度に返却

セットアップ

# 依存関係のインストール
npm install @apollo/server graphql graphql-tag
npm install -D typescript @types/node

# コード生成ツール
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

プロジェクト構成

src/
├── graphql/
│ ├── schema/
│ │ ├── user.graphql
│ │ ├── post.graphql
│ │ └── index.ts
│ ├── resolvers/
│ │ ├── user.resolver.ts
│ │ ├── post.resolver.ts
│ │ └── index.ts
│ ├── context.ts
│ └── server.ts
├── dataloaders/
│ ├── userLoader.ts
│ └── postLoader.ts
├── services/
│ ├── userService.ts
│ └── postService.ts
└── index.ts

スキーマ定義

ポイント: GraphQLスキーマは「API仕様書」としても機能します。type、input、query、mutationを明確に定義しましょう。

# src/graphql/schema/user.graphql
type User {
 id: ID!
 email: String!
 name: String!
 avatar: String
 posts: [Post!]!
 followers: [User!]!
 following: [User!]!
 createdAt: DateTime!
 updatedAt: DateTime!
}

input CreateUserInput {
 email: String!
 name: String!
 password: String!
}

input UpdateUserInput {
 name: String
 avatar: String
}

type Query {
 me: User
 user(id: ID!): User
 users(limit: Int = 10, offset: Int = 0): [User!]!
}

type Mutation {
 createUser(input: CreateUserInput!): User!
 updateUser(id: ID!, input: UpdateUserInput!): User!
 deleteUser(id: ID!): Boolean!
 followUser(userId: ID!): User!
 unfollowUser(userId: ID!): User!
}

# src/graphql/schema/post.graphql
type Post {
 id: ID!
 title: String!
 content: String!
 published: Boolean!
 author: User!
 comments: [Comment!]!
 likes: Int!
 createdAt: DateTime!
 updatedAt: DateTime!
}

type Comment {
 id: ID!
 content: String!
 author: User!
 post: Post!
 createdAt: DateTime!
}

input CreatePostInput {
 title: String!
 content: String!
 published: Boolean = false
}

input UpdatePostInput {
 title: String
 content: String
 published: Boolean
}

extend type Query {
 post(id: ID!): Post
 posts(
 limit: Int = 10
 offset: Int = 0
 authorId: ID
 published: Boolean
 ): PostConnection!
}

type PostConnection {
 edges: [PostEdge!]!
 pageInfo: PageInfo!
 totalCount: Int!
}

type PostEdge {
 node: Post!
 cursor: String!
}

type PageInfo {
 hasNextPage: Boolean!
 hasPreviousPage: Boolean!
 startCursor: String
 endCursor: String
}

extend type Mutation {
 createPost(input: CreatePostInput!): Post!
 updatePost(id: ID!, input: UpdatePostInput!): Post!
 deletePost(id: ID!): Boolean!
 likePost(postId: ID!): Post!
 addComment(postId: ID!, content: String!): Comment!
}

type Subscription {
 postCreated: Post!
 commentAdded(postId: ID!): Comment!
}

コード生成設定

# codegen.yml
schema: "src/graphql/schema/**/*.graphql"
generates:
 src/graphql/__generated__/types.ts:
 plugins:
 - typescript
 - typescript-resolvers
 config:
 contextType: ../context#GraphQLContext
 mappers:
 User: ../../models/User#UserModel
 Post: ../../models/Post#PostModel
 scalars:
 DateTime: Date
# コード生成実行
npx graphql-codegen

注意: リゾルバー内で直接SQLを実行するとN+1問題が発生しやすくなります。DataLoaderを使ってバッチ処理しましょう

リゾルバー実装

// src/graphql/resolvers/user.resolver.ts
import { Resolvers } from '../__generated__/types';
import { GraphQLContext } from '../context';
import { UserService } from '../../services/userService';

export const userResolvers: Resolvers<GraphQLContext> = {
 Query: {
 me: async (_, __, context) => {
 if (!context.userId) return null;
 return context.loaders.user.load(context.userId);
 },

 user: async (_, { id }, context) => {
 return context.loaders.user.load(id);
 },

 users: async (_, { limit, offset }, context) => {
 return context.services.user.findMany({ limit, offset });
 },
 },

 Mutation: {
 createUser: async (_, { input }, context) => {
 return context.services.user.create(input);
 },

 updateUser: async (_, { id, input }, context) => {
 context.assertAuthenticated();
 context.assertOwnership(id);
 return context.services.user.update(id, input);
 },

 deleteUser: async (_, { id }, context) => {
 context.assertAuthenticated();
 context.assertOwnership(id);
 await context.services.user.delete(id);
 return true;
 },

 followUser: async (_, { userId }, context) => {
 context.assertAuthenticated();
 return context.services.user.follow(context.userId!, userId);
 },

 unfollowUser: async (_, { userId }, context) => {
 context.assertAuthenticated();
 return context.services.user.unfollow(context.userId!, userId);
 },
 },

 User: {
 posts: async (user, _, context) => {
 return context.loaders.postsByAuthor.load(user.id);
 },

 followers: async (user, _, context) => {
 return context.loaders.followers.load(user.id);
 },

 following: async (user, _, context) => {
 return context.loaders.following.load(user.id);
 },
 },
};
// src/graphql/resolvers/post.resolver.ts
import { Resolvers } from '../__generated__/types';
import { GraphQLContext } from '../context';

export const postResolvers: Resolvers<GraphQLContext> = {
 Query: {
 post: async (_, { id }, context) => {
 return context.loaders.post.load(id);
 },

 posts: async (_, args, context) => {
 const { limit, offset, authorId, published } = args;

 const [posts, totalCount] = await Promise.all([
 context.services.post.findMany({
 limit: limit + 1, // 次ページ存在確認用
 offset,
 authorId,
 published,
 }),
 context.services.post.count({ authorId, published }),
 ]);

 const hasNextPage = posts.length > limit;
 const edges = posts.slice(0, limit).map((post) => ({
 node: post,
 cursor: Buffer.from(`post:${post.id}`).toString('base64'),
 }));

 return {
 edges,
 pageInfo: {
 hasNextPage,
 hasPreviousPage: offset > 0,
 startCursor: edges[0]?.cursor,
 endCursor: edges[edges.length - 1]?.cursor,
 },
 totalCount,
 };
 },
 },

 Mutation: {
 createPost: async (_, { input }, context) => {
 context.assertAuthenticated();
 const post = await context.services.post.create({
 ...input,
 authorId: context.userId!,
 });

 // サブスクリプション通知
 context.pubsub.publish('POST_CREATED', { postCreated: post });

 return post;
 },

 updatePost: async (_, { id, input }, context) => {
 context.assertAuthenticated();
 const post = await context.loaders.post.load(id);
 context.assertOwnership(post?.authorId);
 return context.services.post.update(id, input);
 },

 likePost: async (_, { postId }, context) => {
 context.assertAuthenticated();
 return context.services.post.like(postId, context.userId!);
 },

 addComment: async (_, { postId, content }, context) => {
 context.assertAuthenticated();
 const comment = await context.services.post.addComment({
 postId,
 content,
 authorId: context.userId!,
 });

 context.pubsub.publish(`COMMENT_ADDED_${postId}`, {
 commentAdded: comment,
 });

 return comment;
 },
 },

 Subscription: {
 postCreated: {
 subscribe: (_, __, context) => {
 return context.pubsub.asyncIterator(['POST_CREATED']);
 },
 },

 commentAdded: {
 subscribe: (_, { postId }, context) => {
 return context.pubsub.asyncIterator([`COMMENT_ADDED_${postId}`]);
 },
 },
 },

 Post: {
 author: async (post, _, context) => {
 return context.loaders.user.load(post.authorId);
 },

 comments: async (post, _, context) => {
 return context.loaders.commentsByPost.load(post.id);
 },
 },
};

DataLoaderでN+1問題を解決

// src/dataloaders/userLoader.ts
import DataLoader from 'dataloader';
import { db } from '../db';
import { UserModel } from '../models/User';

export const createUserLoader = () =>
 new DataLoader<string, UserModel | null>(async (ids) => {
 const users = await db.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);
 });

export const createFollowersLoader = () =>
 new DataLoader<string, UserModel[]>(async (userIds) => {
 const follows = await db.follow.findMany({
 where: { followingId: { in: userIds as string[] } },
 include: { follower: true },
 });

 const followerMap = new Map<string, UserModel[]>();
 for (const follow of follows) {
 const followers = followerMap.get(follow.followingId) ?? [];
 followers.push(follow.follower);
 followerMap.set(follow.followingId, followers);
 }

 return userIds.map((id) => followerMap.get(id) ?? []);
 });

// src/dataloaders/postLoader.ts
export const createPostLoader = () =>
 new DataLoader<string, PostModel | null>(async (ids) => {
 const posts = await db.post.findMany({
 where: { id: { in: ids as string[] } },
 });

 const postMap = new Map(posts.map((post) => [post.id, post]));
 return ids.map((id) => postMap.get(id) ?? null);
 });

export const createPostsByAuthorLoader = () =>
 new DataLoader<string, PostModel[]>(async (authorIds) => {
 const posts = await db.post.findMany({
 where: { authorId: { in: authorIds as string[] } },
 orderBy: { createdAt: 'desc' },
 });

 const postMap = new Map<string, PostModel[]>();
 for (const post of posts) {
 const authorPosts = postMap.get(post.authorId) ?? [];
 authorPosts.push(post);
 postMap.set(post.authorId, authorPosts);
 }

 return authorIds.map((id) => postMap.get(id) ?? []);
 });

Context設定

// src/graphql/context.ts
import { PubSub } from 'graphql-subscriptions';
import { createUserLoader, createFollowersLoader } from '../dataloaders/userLoader';
import { createPostLoader, createPostsByAuthorLoader } from '../dataloaders/postLoader';
import { UserService } from '../services/userService';
import { PostService } from '../services/postService';
import { AuthenticationError, ForbiddenError } from './errors';

export interface GraphQLContext {
 userId: string | null;
 loaders: {
 user: ReturnType<typeof createUserLoader>;
 post: ReturnType<typeof createPostLoader>;
 postsByAuthor: ReturnType<typeof createPostsByAuthorLoader>;
 followers: ReturnType<typeof createFollowersLoader>;
 following: ReturnType<typeof createFollowingLoader>;
 commentsByPost: ReturnType<typeof createCommentsByPostLoader>;
 };
 services: {
 user: UserService;
 post: PostService;
 };
 pubsub: PubSub;
 assertAuthenticated: () => void;
 assertOwnership: (ownerId: string | null | undefined) => void;
}

const pubsub = new PubSub();

export const createContext = async ({ req }): Promise<GraphQLContext> => {
 // 認証トークンからユーザーID取得
 const token = req.headers.authorization?.replace('Bearer ', '');
 const userId = token ? await verifyToken(token) : null;

 return {
 userId,
 loaders: {
 user: createUserLoader(),
 post: createPostLoader(),
 postsByAuthor: createPostsByAuthorLoader(),
 followers: createFollowersLoader(),
 following: createFollowingLoader(),
 commentsByPost: createCommentsByPostLoader(),
 },
 services: {
 user: new UserService(),
 post: new PostService(),
 },
 pubsub,
 assertAuthenticated() {
 if (!this.userId) {
 throw new AuthenticationError('Authentication required');
 }
 },
 assertOwnership(ownerId) {
 if (ownerId !== this.userId) {
 throw new ForbiddenError('Not authorized');
 }
 },
 };
};

サーバー設定

// src/graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext, GraphQLContext } from './context';

async function startServer() {
 const app = express();
 const httpServer = http.createServer(app);

 // スキーマ作成
 const schema = makeExecutableSchema({ typeDefs, resolvers });

 // WebSocket設定(サブスクリプション用)
 const wsServer = new WebSocketServer({
 server: httpServer,
 path: '/graphql',
 });

 const serverCleanup = useServer(
 {
 schema,
 context: async (ctx) => createContext({ req: ctx.extra.request }),
 },
 wsServer
 );

 // Apollo Server設定
 const server = new ApolloServer<GraphQLContext>({
 schema,
 plugins: [
 ApolloServerPluginDrainHttpServer({ httpServer }),
 {
 async serverWillStart() {
 return {
 async drainServer() {
 await serverCleanup.dispose();
 },
 };
 },
 },
 ],
 });

 await server.start();

 app.use(
 '/graphql',
 cors<cors.CorsRequest>(),
 express.json(),
 expressMiddleware(server, {
 context: createContext,
 })
 );

 const PORT = process.env.PORT || 4000;
 httpServer.listen(PORT, () => {
 console.log(`Server ready at http://localhost:${PORT}/graphql`);
 console.log(`Subscriptions ready at ws://localhost:${PORT}/graphql`);
 });
}

startServer();

エラーハンドリング

// src/graphql/errors.ts
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,
 },
 });
 }
}

export class NotFoundError extends GraphQLError {
 constructor(resource: string, id: string) {
 super(`${resource} not found: ${id}`, {
 extensions: { code: 'NOT_FOUND', resource, id },
 });
 }
}

クライアント側の利用例

// クライアント側コード例
import { gql, useQuery, useMutation, useSubscription } from '@apollo/client';

const GET_POSTS = gql`
 query GetPosts($limit: Int, $offset: Int) {
 posts(limit: $limit, offset: $offset) {
 edges {
 node {
 id
 title
 content
 author {
 id
 name
 avatar
 }
 createdAt
 }
 }
 pageInfo {
 hasNextPage
 endCursor
 }
 totalCount
 }
 }
`;

const CREATE_POST = gql`
 mutation CreatePost($input: CreatePostInput!) {
 createPost(input: $input) {
 id
 title
 content
 }
 }
`;

const POST_CREATED = gql`
 subscription OnPostCreated {
 postCreated {
 id
 title
 author {
 name
 }
 }
 }
`;

// React Hooksで利用
function PostList() {
 const { data, loading, fetchMore } = useQuery(GET_POSTS, {
 variables: { limit: 10, offset: 0 },
 });

 const [createPost] = useMutation(CREATE_POST, {
 refetchQueries: ['GetPosts'],
 });

 useSubscription(POST_CREATED, {
 onData: ({ data }) => {
 console.log('New post:', data.data.postCreated);
 },
 });

 // ...
}

GraphQLで設計するもの

GraphQLは、必要なフィールドだけ取得できる便利なAPIですが、スキーマ設計が曖昧だと保守が難しくなります。型、Query、Mutation、エラー形式、認可の境界を先に決めることが大切です。

RESTより柔軟に見える一方で、N+1問題や過剰に深いクエリなど、GraphQL特有の注意点があります。DataLoader、クエリ深度制限、認可チェックを組み合わせて、便利さと安全性のバランスを取ります。

実装時の観点

  • スキーマがドメインの言葉で表現されているか
  • Resolverにビジネスロジックを詰め込みすぎていないか
  • 認可をフィールド単位でも確認しているか
  • クエリの深さや複雑度を制限しているか

動作確認

GraphQL APIを作ったら、次を確認します。

確認項目見ること
SchemaQuery/Mutationの型が意図通り
Resolvernullや権限なしを正しく扱う
Validation不正なqueryでエラーが返る
認可他人の非公開データを読めない
N+1一覧取得時のSQL回数が増えすぎない

PlaygroundやGraphQL IDEで動くだけでは不十分です。ログやDBクエリ数も合わせて確認します。

練習

ブログAPIを題材に次を実装してください。

  1. PostUser 型を定義する
  2. posts Queryで投稿一覧を返す
  3. createPost Mutationを作る
  4. ログインユーザーだけ投稿できるようにする
  5. DataLoaderで投稿者取得のN+1を減らす

次のステップ

型安全なAPI設計を比較したい場合は tRPC実践ガイドREST APIのテストを書こう を確認してください。HTTPの基本は HTTPリクエストの読み方 も役立ちます。

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR