mcpField
G
graphql

graphql

github.com/bobmatnyc開発78337 回インストール15 週間前に更新
リポジトリ

graphql について

graphql は、柔軟な API を構築する開発者向けの GraphQL 設計・実装ガイドです。型付きスキーマ、クエリ、ミューテーション、リゾルバー、サブスクリプション、クライアント利用を扱い、サーバー実装とデータローディングの例も紹介します。

主な機能

  1. 01スキーマ定義と型システム
  2. 02クエリ、ミューテーション、サブスクリプション
  3. 03リゾルバーと DataLoader
  4. 04エラー処理とページネーションのパターン
  5. 05Apollo Server、GraphQL Yoga などの実装
  6. 06Apollo Client、urql などのクライアント例

活用シーン

  1. 01複数のクライアント向けの柔軟な API 構築
  2. 02ネストしたデータとクライアント指定フィールドへの対応
  3. 03リアルタイム購読とデータ集約の実装
  4. 04N+1 クエリの削減とページネーション設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill graphql

インストール後、Claude Code で/graphqlを入力して読み込まれたか確認します。

同梱ファイルmetadata.json

詳細

GraphQL スキル

概要

GraphQL は、クライアントが必要なデータだけを正確に要求できる、API 向けのクエリ言語およびランタイムです。強い型付けのスキーマと単一エンドポイントのアーキテクチャを提供し、REST API でよく見られる過剰取得や取得不足の問題を解消します。

使用する場面

  • Web、モバイル、IoT など、複数のクライアント種別に対応する柔軟な API の構築
  • ネストされた関係を含む複雑なデータ要件
  • 帯域幅の効率性が求められるモバイルファーストのアプリケーション
  • API のバージョン管理の複雑さの軽減
  • サブスクリプションを使ったリアルタイムデータ
  • マイクロサービスの集約とフェデレーション
  • 強い型付けとイントロスペクションによる開発者エクスペリエンスの向上

クイックスタート

1. スキーマを定義する(SDL)
# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  publishedAt: DateTime
}

type Query {
  user(id: ID!): User
  users: [User!]!
  post(id: ID!): Post
}

type Mutation {
  createPost(title: String!, content: String!, authorId: ID!): Post!
  updatePost(id: ID!, title: String, content: String): Post!
  deletePost(id: ID!): Boolean!
}
2. リゾルバーを記述する(TypeScript + Apollo Server)
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';

// Load schema
const typeDefs = readFileSync('./schema.graphql', 'utf-8');

// Mock data
const users = [
  { id: '1', name: 'Alice', email: '[email protected]' },
  { id: '2', name: 'Bob', email: '[email protected]' },
];

const posts = [
  { id: '1', title: 'GraphQL Intro', content: 'Learning GraphQL...', authorId: '1' },
  { id: '2', title: 'Apollo Server', content: 'Building APIs...', authorId: '1' },
];

// Resolvers
const resolvers = {
  Query: {
    user: (_, { id }) => users.find(u => u.id === id),
    users: () => users,
    post: (_, { id }) => posts.find(p => p.id === id),
  },

  Mutation: {
    createPost: (_, { title, content, authorId }) => {
      const post = {
        id: String(posts.length + 1),
        title,
        content,
        authorId,
      };
      posts.push(post);
      return post;
    },

    updatePost: (_, { id, title, content }) => {
      const post = posts.find(p => p.id === id);
      if (!post) throw new Error('Post not found');
      if (title) post.title = title;
      if (content) post.content = content;
      return post;
    },

    deletePost: (_, { id }) => {
      const index = posts.findIndex(p => p.id === id);
      if (index === -1) return false;
      posts.splice(index, 1);
      return true;
    },
  },

  User: {
    posts: (user) => posts.filter(p => p.authorId === user.id),
  },

  Post: {
    author: (post) => users.find(u => u.id === post.authorId),
  },
};

// Create server
const server = new ApolloServer({ typeDefs, resolvers });

startStandaloneServer(server, {
  listen: { port: 4000 },
}).then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});
3. データをクエリする(クライアント)
// Using Apollo Client
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000',
  cache: new InMemoryCache(),
});

// Query
const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
        publishedAt
      }
    }
  }
`;

const { data } = await client.query({
  query: GET_USER,
  variables: { id: '1' },
});

// Mutation
const CREATE_POST = gql`
  mutation CreatePost($title: String!, $content: String!, $authorId: ID!) {
    createPost(title: $title, content: $content, authorId: $authorId) {
      id
      title
      content
    }
  }
`;

const { data: postData } = await client.mutate({
  mutation: CREATE_POST,
  variables: {
    title: 'New Post',
    content: 'Hello GraphQL!',
    authorId: '1',
  },
});

コアコンセプト

GraphQL の基本
  • スキーマファースト設計:Schema Definition Language(SDL)で API コントラクトを定義します
  • 型安全性:強い型付けのスキーマが、実行時とビルド時に適用されます
  • 単一エンドポイント:すべてのクエリとミューテーションが 1 つの URL(例:/graphql)を経由します
  • クライアント指定のクエリ:クライアントは必要なデータだけを正確に要求します
  • 階層的なデータ:クエリは返されるデータの形状を反映します
  • イントロスペクション:スキーマは自己文書化されており、クエリで照会できます
オペレーション
# Query - Read data (GET-like)
query GetUser {
  user(id: "1") {
    name
  }
}

# Mutation - Modify data (POST/PUT/DELETE-like)
mutation CreateUser {
  createUser(name: "Alice", email: "[email protected]") {
    id
    name
  }
}

# Subscription - Real-time updates (WebSocket)
subscription OnPostCreated {
  postCreated {
    id
    title
    author {
      name
    }
  }
}
フィールドと引数
type Query {
  # Field with arguments
  user(id: ID!): User
  users(limit: Int = 10, offset: Int = 0): [User!]!

  # Search with multiple arguments
  searchPosts(
    query: String!
    category: String
    limit: Int = 20
  ): [Post!]!
}

スキーマ定義言語(SDL)

基本的な型定義
type User {
  id: ID!              # Non-null ID scalar
  name: String!        # Non-null String
  email: String!
  age: Int             # Nullable Int
  isActive: Boolean!
  posts: [Post!]!      # Non-null list of non-null Posts
  profile: Profile     # Nullable object type
}

type Profile {
  bio: String
  avatarUrl: String
  website: String
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  tags: [String!]      # Non-null list, nullable elements
  publishedAt: DateTime
}
入力型(ミューテーション用)
input CreateUserInput {
  name: String!
  email: String!
  age: Int
}

input UpdateUserInput {
  name: String
  email: String
  age: Int
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
}
インターフェース
interface Node {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type User implements Node {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  name: String!
  email: String!
}

type Post implements Node {
  id: ID!
  createdAt: DateTime!
  updatedAt: DateTime!
  title: String!
  content: String!
}

type Query {
  node(id: ID!): Node  # Can return User or Post
}
ユニオン
union SearchResult = User | Post | Comment

type Query {
  search(query: String!): [SearchResult!]!
}

# Client query with fragments
query Search {
  search(query: "graphql") {
    ... on User {
      name
      email
    }
    ... on Post {
      title
      content
    }
    ... on Comment {
      text
      author { name }
    }
  }
}
列挙型
enum Role {
  ADMIN
  MODERATOR
  USER
  GUEST
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type User {
  id: ID!
  name: String!
  role: Role!
}

type Post {
  id: ID!
  title: String!
  status: PostStatus!
}

型システム

スカラー型
# Built-in scalars
scalar Int       # Signed 32-bit integer
scalar Float     # Signed double-precision floating-point
scalar String    # UTF-8 character sequence
scalar Boolean   # true or false
scalar ID        # Unique identifier (serialized as String)

# Custom scalars
scalar DateTime  # ISO 8601 timestamp
scalar Email     # Email address
scalar URL       # Valid URL
scalar JSON      # Arbitrary JSON
scalar Upload    # File upload
カスタムスカラーの実装
// DateTime scalar (TypeScript)
import { GraphQLScalarType, Kind } from 'graphql';

const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO 8601 DateTime',

  // Serialize to client (output)
  serialize(value: Date) {
    return value.toISOString();
  },

  // Parse from client (input)
  parseValue(value: string) {
    return new Date(value);
  },

  // Parse from query literal
  parseLiteral(ast) {
    if (ast.kind === Kind.STRING) {
      return new Date(ast.value);
    }
    return null;
  },
});

// Add to resolvers
const resolvers = {
  DateTime: DateTimeScalar,
  // ... other resolvers
};
非 Null とリスト
type User {
  name: String!           # Non-null String
  email: String           # Nullable String

  tags: [String!]!        # Non-null list of non-null Strings
  friends: [User!]        # Nullable list of non-null Users
  posts: [Post]!          # Non-null list of nullable Posts
  comments: [Comment]     # Nullable list of nullable Comments
}

クエリとミューテーション

クエリ変数
// Define query with variables
const GET_USER = gql`
  query GetUser($id: ID!, $includePosts: Boolean = false) {
    user(id: $id) {
      id
      name
      email
      posts @include(if: $includePosts) {
        id
        title
      }
    }
  }
`;

// Execute with variables
const { data } = await client.query({
  query: GET_USER,
  variables: {
    id: '1',
    includePosts: true,
  },
});
エイリアス
query {
  # Fetch same field with different arguments
  user1: user(id: "1") {
    name
  }
  user2: user(id: "2") {
    name
  }

  # Alias for clarity
  currentUser: me {
    id
    name
  }
}
フラグメント
# Define reusable fragment
fragment UserFields on User {
  id
  name
  email
  createdAt
}

fragment PostSummary on Post {
  id
  title
  publishedAt
  author {
    ...UserFields
  }
}

# Use fragments in query
query {
  user(id: "1") {
    ...UserFields
    posts {
      ...PostSummary
    }
  }
}
ディレクティブ
# Built-in directives
query GetUser($id: ID!, $withPosts: Boolean!, $skipEmail: Boolean!) {
  user(id: $id) {
    name
    email @skip(if: $skipEmail)
    posts @include(if: $withPosts) {
      title
    }
  }
}

# Custom directive definition
directive @auth(requires: Role = USER) on FIELD_DEFINITION

type Query {
  users: [User!]! @auth(requires: ADMIN)
  me: User! @auth
}
ミューテーションのベストプラクティス
# Single mutation with input type
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!
}

input CreatePostInput {
  title: String!
  content: String!
  categoryId: ID!
  tags: [String!]
}

# Payload pattern for mutations
type CreatePostPayload {
  post: Post          # Created resource
  userErrors: [UserError!]!  # Client errors
  success: Boolean!
}

type UserError {
  field: String!      # Which field caused error
  message: String!    # Human-readable message
}

リゾルバーと DataLoader

リゾルバーのシグネチャ
type Resolver<TParent, TArgs, TContext, TResult> = (
  parent: TParent,      // Parent object
  args: TArgs,          // Field arguments
  context: TContext,    // Shared context (auth, db, etc.)
  info: GraphQLResolveInfo  // Query metadata
) => TResult | Promise<TResult>;
基本的なリゾルバー
const resolvers = {
  Query: {
    user: async (_, { id }, { db }) => {
      return db.users.findById(id);
    },

    users: async (_, { limit = 10, offset = 0 }, { db }) => {
      return db.users.findMany({ limit, offset });
    },
  },

  Mutation: {
    createUser: async (_, { input }, { db, userId }) => {
      if (!userId) {
        throw new Error('Authentication required');
      }

      const user = await db.users.create(input);
      return { user, userErrors: [], success: true };
    },
  },

  User: {
    // Field resolver - only called if client requests 'posts'
    posts: async (user, _, { db }) => {
      return db.posts.findByAuthorId(user.id);
    },

    // Computed field
    fullName: (user) => {
      return `${user.firstName} ${user.lastName}`;
    },
  },
};
N+1 問題
// ❌ BAD - N+1 queries
const resolvers = {
  Query: {
    users: () => db.users.findMany(), // 1 query
  },
  User: {
    // Called for EACH user - N queries!
    posts: (user) => db.posts.findByAuthorId(user.id),
  },
};

// Querying 100 users = 1 + 100 = 101 database queries!
DataLoader による解決策
import DataLoader from 'dataloader';

// Batch function - receives array of keys
async function batchLoadPosts(authorIds: string[]) {
  const posts = await db.posts.findByAuthorIds(authorIds);

  // Group by author ID
  const postsByAuthor = authorIds.map(authorId =>
    posts.filter(post => post.authorId === authorId)
  );

  return postsByAuthor;
}

// Create context with loaders
function createContext({ req }) {
  return {
    db,
    userId: req.userId,
    loaders: {
      posts: new DataLoader(batchLoadPosts),
    },
  };
}

// ✅ GOOD - Batched queries
const resolvers = {
  Query: {
    users: () => db.users.findMany(), // 1 query
  },
  User: {
    // Uses DataLoader - batches all requests into 1 query!
    posts: (user, _, { loaders }) => {
      return loaders.posts.load(user.id);
    },
  },
};

// Querying 100 users = 1 + 1 = 2 database queries!
DataLoader の高度なパターン
// DataLoader with caching
const userLoader = new DataLoader(
  async (ids) => {
    const users = await db.users.findByIds(ids);
    return ids.map(id => users.find(u => u.id === id));
  },
  {
    cache: true,           // Enable caching (default)
    maxBatchSize: 100,     // Limit batch size
    batchScheduleFn: (cb) => setTimeout(cb, 10), // Debounce batching
  }
);

// Cache manipulation
userLoader.clear(id);              // Clear single key
userLoader.clearAll();             // Clear entire cache
userLoader.prime(id, user);        // Prime cache with value

サブスクリプション

WebSocket の設定(Apollo Server)
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import express from 'express';

const app = express();
const httpServer = createServer(app);

const schema = makeExecutableSchema({ typeDefs, resolvers });

// WebSocket server for subscriptions
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});

const serverCleanup = useServer({ schema }, wsServer);

const server = new ApolloServer({
  schema,
  plugins: [
    ApolloServerPluginDrainHttpServer({ httpServer }),
    {
      async serverWillStart() {
        return {
          async drainServer() {
            await serverCleanup.dispose();
          },
        };
      },
    },
  ],
});

await server.start();
app.use('/graphql', express.json(), expressMiddleware(server));

httpServer.listen(4000);
サブスクリプションスキーマ
type Subscription {
  postCreated: Post!
  postUpdated(id: ID!): Post!
  messageAdded(channelId: ID!): Message!
  userStatusChanged(userId: ID!): UserStatus!
}

type Message {
  id: ID!
  text: String!
  author: User!
  channelId: ID!
  createdAt: DateTime!
}

enum UserStatus {
  ONLINE
  OFFLINE
  AWAY
}
サブスクリプションリゾルバー(PubSub)
import { PubSub } from 'graphql-subscriptions';

const pubsub = new PubSub();

const resolvers = {
  Mutation: {
    createPost: async (_, { input }, { db }) => {
      const post = await db.posts.create(input);

      // Publish to subscribers
      pubsub.publish('POST_CREATED', { postCreated: post });

      return { post, success: true, userErrors: [] };
    },

    sendMessage: async (_, { channelId, text }, { db, userId }) => {
      const message = await db.messages.create({
        channelId,
        text,
        authorId: userId,
      });

      pubsub.publish(`MESSAGE_${channelId}`, {
        messageAdded: message,
      });

      return message;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
    },

    postUpdated: {
      subscribe: (_, { id }) => pubsub.asyncIterator([`POST_UPDATED_${id}`]),
    },

    messageAdded: {
      subscribe: (_, { channelId }) => {
        return pubsub.asyncIterator([`MESSAGE_${channelId}`]);
      },
    },
  },
};
クライアントサブスクリプション(Apollo Client)
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';

// HTTP link for queries and mutations
const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql',
});

// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:4000/graphql',
  })
);

// Split based on operation type
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(),
});

// Use subscription
const MESSAGES_SUBSCRIPTION = gql`
  subscription OnMessageAdded($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id
      text
      author {
        name
      }
      createdAt
    }
  }
`;

function ChatComponent({ channelId }) {
  const { data, loading } = useSubscription(MESSAGES_SUBSCRIPTION, {
    variables: { channelId },
  });

  if (loading) return <p>Loading...</p>;

  return <div>New message: {data.messageAdded.text}</div>;
}
Redis PubSub(本番環境)
import { RedisPubSub } from 'graphql-redis-subscriptions';
import Redis from 'ioredis';

const options = {
  host: 'localhost',
  port: 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
};

const pubsub = new RedisPubSub({
  publisher: new Redis(options),
  subscriber: new Redis(options),
});

// Use same as in-memory PubSub
pubsub.publish('POST_CREATED', { postCreated: post });
pubsub.asyncIterator(['POST_CREATED']);

エラー処理

エラーの種類
import { GraphQLError } from 'graphql';

// Custom error classes
class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'UNAUTHENTICATED',
        http: { status: 401 },
      },
    });
  }
}

class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: {
        code: 'FORBIDDEN',
        http: { status: 403 },
      },
    });
  }
}

class ValidationError extends GraphQLError {
  constructor(message: string, invalidFields: Record<string, string>) {
    super(message, {
      extensions: {
        code: 'BAD_USER_INPUT',
        invalidFields,
      },
    });
  }
}
リゾルバーでのエラーのスロー
const resolvers = {
  Query: {
    user: async (_, { id }, { db, userId }) => {
      if (!userId) {
        throw new AuthenticationError('Must be logged in');
      }

      const user = await db.users.findById(id);
      if (!user) {
        throw new GraphQLError('User not found', {
          extensions: { code: 'NOT_FOUND' },
        });
      }

      return user;
    },
  },

  Mutation: {
    createPost: async (_, { input }, { db, userId }) => {
      const errors: Record<string, string> = {};

      if (!input.title || input.title.length < 3) {
        errors.title = 'Title must be at least 3 characters';
      }

      if (!input.content) {
        errors.content = 'Content is required';
      }

      if (Object.keys(errors).length > 0) {
        throw new ValidationError('Invalid input', errors);
      }

      const post = await db.posts.create({ ...input, authorId: userId });
      return { post, success: true, userErrors: [] };
    },
  },
};
エラーレスポンスの形式
{
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND"
      }
    }
  ],
  "data": {
    "user": null
  }
}
フィールドレベルのエラー処理
// Nullable fields allow partial results
type Query {
  user(id: ID!): User        # null on error
  users: [User!]!            # throws on error
  post(id: ID!): Post        # null on error
}

// Resolver can return null instead of throwing
const resolvers = {
  Query: {
    user: async (_, { id }, { db }) => {
      try {
        return await db.users.findById(id);
      } catch (error) {
        console.error('Failed to fetch user:', error);
        return null;  // Returns null instead of error
      }
    },
  },
};

スキーマ設計パターン

Relay カーソル接続
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

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

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

type Query {
  posts(
    first: Int
    after: String
    last: Int
    before: String
  ): PostConnection!
}
Relay 接続リゾルバー
import { fromGlobalId, toGlobalId } from 'graphql-relay';

function encodeCursor(id: string): string {
  return Buffer.from(`cursor:${id}`).toString('base64');
}

function decodeCursor(cursor: string): string {
  return Buffer.from(cursor, 'base64').toString('utf-8').replace('cursor:', '');
}

const resolvers = {
  Query: {
    posts: async (_, { first = 10, after }, { db }) => {
      const startId = after ? decodeCursor(after) : null;

      // Fetch first + 1 to determine hasNextPage
      const posts = await db.posts.findMany({
        where: startId ? { id: { gt: startId } } : {},
        take: first + 1,
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = posts.length > first;
      const nodes = hasNextPage ? posts.slice(0, -1) : posts;

      const edges = nodes.map(node => ({
        node,
        cursor: encodeCursor(node.id),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount: await db.posts.count(),
      };
    },
  },
};
オフセットページネーション(よりシンプルな方法)
type PostsResponse {
  posts: [Post!]!
  total: Int!
  hasMore: Boolean!
}

type Query {
  posts(limit: Int = 10, offset: Int = 0): PostsResponse!
}
グローバルオブジェクト識別(Relay)
interface Node {
  id: ID!  # Global unique ID
}

type User implements Node {
  id: ID!
  name: String!
}

type Post implements Node {
  id: ID!
  title: String!
}

type Query {
  node(id: ID!): Node
}
const resolvers = {
  Query: {
    node: async (_, { id }, { db }) => {
      const { type, id: rawId } = fromGlobalId(id);

      switch (type) {
        case 'User':
          return db.users.findById(rawId);
        case 'Post':
          return db.posts.findById(rawId);
        default:
          return null;
      }
    },
  },

  User: {
    id: (user) => toGlobalId('User', user.id),
  },

  Post: {
    id: (post) => toGlobalId('Post', post.id),
  },
};

サーバー実装

Apollo Server(TypeScript)
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  plugins: [
    // Custom plugin
    {
      async requestDidStart() {
        return {
          async willSendResponse({ response }) {
            console.log('Response:', response);
          },
        };
      },
    },
  ],
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => ({
    token: req.headers.authorization,
    db: database,
  }),
});
GraphQL Yoga(最新の代替手段)
import { createYoga, createSchema } from 'graphql-yoga';
import { createServer } from 'node:http';

const yoga = createYoga({
  schema: createSchema({
    typeDefs,
    resolvers,
  }),
  graphiql: true,
  context: ({ request }) => ({
    userId: request.headers.get('x-user-id'),
  }),
});

const server = createServer(yoga);
server.listen(4000, () => {
  console.log('Server on http://localhost:4000/graphql');
});
Graphene(Python/Django)
import graphene
from graphene_django import DjangoObjectType
from .models import User, Post

class UserType(DjangoObjectType):
    class Meta:
        model = User
        fields = '__all__'

class PostType(DjangoObjectType):
    class Meta:
        model = Post
        fields = '__all__'

class Query(graphene.ObjectType):
    users = graphene.List(UserType)
    user = graphene.Field(UserType, id=graphene.ID(required=True))
    posts = graphene.List(PostType)

    def resolve_users(self, info):
        return User.objects.all()

    def resolve_user(self, info, id):
        return User.objects.get(pk=id)

    def resolve_posts(self, info):
        return Post.objects.all()

class CreateUser(graphene.Mutation):
    class Arguments:
        name = graphene.String(required=True)
        email = graphene.String(required=True)

    user = graphene.Field(UserType)
    success = graphene.Boolean()

    def mutate(self, info, name, email):
        user = User.objects.create(name=name, email=email)
        return CreateUser(user=user, success=True)

class Mutation(graphene.ObjectType):
    create_user = CreateUser.Field()

schema = graphene.Schema(query=Query, mutation=Mutation)
Strawberry(Python、最新)
import strawberry
from typing import List, Optional
from datetime import datetime

@strawberry.type
class User:
    id: strawberry.ID
    name: str
    email: str
    created_at: datetime

@strawberry.type
class Post:
    id: strawberry.ID
    title: str
    content: str
    author_id: strawberry.ID

@strawberry.input
class CreateUserInput:
    name: str
    email: str

@strawberry.type
class Query:
    @strawberry.field
    def users(self) -> List[User]:
        return User.objects.all()

    @strawberry.field
    def user(self, id: strawberry.ID) -> Optional[User]:
        return User.objects.get(pk=id)

@strawberry.type
class Mutation:
    @strawberry.mutation
    def create_user(self, input: CreateUserInput) -> User:
        return User.objects.create(**input.__dict__)

schema = strawberry.Schema(query=Query, mutation=Mutation)

# FastAPI integration
from strawberry.fastapi import GraphQLRouter

app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")

クライアント連携

Apollo Client(React)
import { ApolloClient, InMemoryCache, ApolloProvider, useQuery, useMutation } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
  cache: new InMemoryCache(),
});

function App() {
  return (
    <ApolloProvider client={client}>
      <UserList />
    </ApolloProvider>
  );
}

function UserList() {
  const { loading, error, data, refetch } = useQuery(GET_USERS);
  const [createUser] = useMutation(CREATE_USER, {
    refetchQueries: [{ query: GET_USERS }],
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      {data.users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
      <button onClick={() => createUser({ variables: { name: 'New User' } })}>
        Add User
      </button>
    </div>
  );
}
urql(軽量な代替手段)
import { createClient, Provider, useQuery, useMutation } from 'urql';

const client = createClient({
  url: 'http://localhost:4000/graphql',
});

function App() {
  return (
    <Provider value={client}>
      <UserList />
    </Provider>
  );
}

function UserList() {
  const [result, reexecuteQuery] = useQuery({ query: GET_USERS });
  const [, createUser] = useMutation(CREATE_USER);

  const { data, fetching, error } = result;

  if (fetching) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      {data.users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}
graphql-request(最小構成)
import { GraphQLClient, gql } from 'graphql-request';

const client = new GraphQLClient('http://localhost:4000/graphql');

async function fetchUsers() {
  const query = gql`
    query {
      users {
        id
        name
      }
    }
  `;

  const data = await client.request(query);
  return data.users;
}

async function createUser(name: string) {
  const mutation = gql`
    mutation CreateUser($name: String!) {
      createUser(name: $name) {
        id
        name
      }
    }
  `;

  const data = await client.request(mutation, { name });
  return data.createUser;
}
TanStack Query + GraphQL
import { useQuery, useMutation, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GraphQLClient } from 'graphql-request';

const graphQLClient = new GraphQLClient('http://localhost:4000/graphql');

function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const { users } = await graphQLClient.request(GET_USERS);
      return users;
    },
  });
}

function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (name: string) => {
      const { createUser } = await graphQLClient.request(CREATE_USER, { name });
      return createUser;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

TypeScript コード生成

GraphQL Code Generator のセットアップ
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
      withComponent: false
      skipTypename: false
      enumsAsTypes: true
生成された型
// src/queries/users.graphql
// query GetUsers {
//   users {
//     id
//     name
//     email
//   }
// }

// Generated types
export type GetUsersQuery = {
  __typename?: 'Query';
  users: Array<{
    __typename?: 'User';
    id: string;
    name: string;
    email: string;
  }>;
};

export function useGetUsersQuery(
  baseOptions?: Apollo.QueryHookOptions<GetUsersQuery, GetUsersQueryVariables>
) {
  return Apollo.useQuery<GetUsersQuery, GetUsersQueryVariables>(
    GetUsersDocument,
    baseOptions
  );
}
生成された型を使う
import { useGetUsersQuery, useCreateUserMutation } from './generated/graphql';

function UserList() {
  const { data, loading, error } = useGetUsersQuery();
  const [createUser] = useCreateUserMutation();

  // Fully typed!
  const users = data?.users; // Type: User[] | undefined
}

認証と認可

コンテキストベースの認証
import jwt from 'jsonwebtoken';

async function createContext({ req }) {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return { userId: null, db };
  }

  try {
    const { userId } = jwt.verify(token, process.env.JWT_SECRET);
    return { userId, db };
  } catch (error) {
    return { userId: null, db };
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

await startStandaloneServer(server, {
  context: createContext,
});
リゾルバーレベルの認証
const resolvers = {
  Query: {
    me: (_, __, { userId }) => {
      if (!userId) {
        throw new AuthenticationError('Not authenticated');
      }
      return db.users.findById(userId);
    },

    users: (_, __, { userId, db }) => {
      if (!userId) {
        throw new AuthenticationError('Not authenticated');
      }

      const user = db.users.findById(userId);
      if (user.role !== 'ADMIN') {
        throw new ForbiddenError('Admin access required');
      }

      return db.users.findMany();
    },
  },
};
ディレクティブベースの認証
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

// Schema with directive
const typeDefs = gql`
  directive @auth(requires: Role = USER) on FIELD_DEFINITION

  enum Role {
    ADMIN
    USER
  }

  type Query {
    users: [User!]! @auth(requires: ADMIN)
    me: User! @auth
  }
`;

// Directive transformer
function authDirectiveTransformer(schema, directiveName = 'auth') {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0];

      if (authDirective) {
        const { requires } = authDirective;
        const { resolve = defaultFieldResolver } = fieldConfig;

        fieldConfig.resolve = async (source, args, context, info) => {
          if (!context.userId) {
            throw new AuthenticationError('Not authenticated');
          }

          if (requires) {
            const user = await context.db.users.findById(context.userId);
            if (user.role !== requires) {
              throw new ForbiddenError(`${requires} role required`);
            }
          }

          return resolve(source, args, context, info);
        };
      }

      return fieldConfig;
    },
  });
}

let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = authDirectiveTransformer(schema);

パフォーマンス最適化

クエリ複雑度の分析
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
    }),
  ],
});
永続クエリ(APQ)
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginInlineTraceDisabled } from '@apollo/server/plugin/disabled';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  persistedQueries: {
    cache: new Map(), // Or Redis
  },
});

// Client sends hash instead of full query
// Reduces payload size by ~80%
レスポンスキャッシュ
import responseCachePlugin from '@apollo/server-plugin-response-cache';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    responseCachePlugin({
      sessionId: (context) => context.userId || null,
      shouldReadFromCache: (context) => !context.userId, // Cache only public queries
    }),
  ],
});

// Schema directive for cache control
const typeDefs = gql`
  type Query {
    posts: [Post!]! @cacheControl(maxAge: 60)
    user(id: ID!): User @cacheControl(maxAge: 30)
  }
`;
フィールドレベルのキャッシュ
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';

const cache = new InMemoryLRUCache({
  maxSize: Math.pow(2, 20) * 100, // 100 MB
  ttl: 300, // 5 minutes
});

const resolvers = {
  Query: {
    user: async (_, { id }, { cache, db }) => {
      const cacheKey = `user:${id}`;
      const cached = await cache.get(cacheKey);

      if (cached) {
        return JSON.parse(cached);
      }

      const user = await db.users.findById(id);
      await cache.set(cacheKey, JSON.stringify(user), { ttl: 60 });

      return user;
    },
  },
};

ファイルアップロード

スキーマ
scalar Upload

type Mutation {
  uploadFile(file: Upload!): File!
  uploadMultiple(files: [Upload!]!): [File!]!
}

type File {
  filename: String!
  mimetype: String!
  encoding: String!
  url: String!
}
サーバー(graphql-upload)
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { GraphQLUpload } from 'graphql-upload/GraphQLUpload.mjs';
import fs from 'fs';
import path from 'path';

app.use('/graphql', graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }));

const resolvers = {
  Upload: GraphQLUpload,

  Mutation: {
    uploadFile: async (_, { file }) => {
      const { createReadStream, filename, mimetype, encoding } = await file;

      const stream = createReadStream();
      const uploadPath = path.join(__dirname, 'uploads', filename);

      await new Promise((resolve, reject) => {
        stream
          .pipe(fs.createWriteStream(uploadPath))
          .on('finish', resolve)
          .on('error', reject);
      });

      return {
        filename,
        mimetype,
        encoding,
        url: `/uploads/${filename}`,
      };
    },
  },
};
クライアント(Apollo Client)
import { useMutation } from '@apollo/client';

const UPLOAD_FILE = gql`
  mutation UploadFile($file: Upload!) {
    uploadFile(file: $file) {
      filename
      url
    }
  }
`;

function FileUpload() {
  const [uploadFile] = useMutation(UPLOAD_FILE);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    uploadFile({ variables: { file } });
  };

  return <input type="file" onChange={handleFileChange} />;
}

テスト

リゾルバーの単体テスト
import { describe, it, expect, vi } from 'vitest';

describe('User Resolvers', () => {
  it('should fetch user by ID', async () => {
    const mockDb = {
      users: {
        findById: vi.fn().mockResolvedValue({
          id: '1',
          name: 'Alice',
          email: '[email protected]',
        }),
      },
    };

    const result = await resolvers.Query.user(
      null,
      { id: '1' },
      { db: mockDb, userId: '1' },
      {} as any
    );

    expect(result.name).toBe('Alice');
    expect(mockDb.users.findById).toHaveBeenCalledWith('1');
  });

  it('should throw error when not authenticated', async () => {
    await expect(
      resolvers.Query.me(null, {}, { userId: null }, {} as any)
    ).rejects.toThrow('Not authenticated');
  });
});
統合テスト(Apollo Server)
import { ApolloServer } from '@apollo/server';
import assert from 'assert';

it('fetches users', async () => {
  const server = new ApolloServer({ typeDefs, resolvers });

  const response = await server.executeOperation({
    query: 'query { users { id name } }',
  });

  assert(response.body.kind === 'single');
  expect(response.body.singleResult.errors).toBeUndefined();
  expect(response.body.singleResult.data?.users).toHaveLength(2);
});

it('creates user', async () => {
  const response = await server.executeOperation({
    query: `
      mutation CreateUser($input: CreateUserInput!) {
        createUser(input: $input) {
          user { id name }
          success
        }
      }
    `,
    variables: {
      input: { name: 'Charlie', email: '[email protected]' },
    },
  });

  assert(response.body.kind === 'single');
  expect(response.body.singleResult.data?.createUser.success).toBe(true);
});
E2E テスト(Supertest)
import request from 'supertest';
import { app } from './server';

describe('GraphQL API', () => {
  it('should query users', async () => {
    const response = await request(app)
      .post('/graphql')
      .send({
        query: '{ users { id name } }',
      })
      .expect(200);

    expect(response.body.data.users).toBeDefined();
  });

  it('should require authentication', async () => {
    const response = await request(app)
      .post('/graphql')
      .send({
        query: '{ me { id } }',
      })
      .expect(200);

    expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
  });
});

本番環境のパターン

スキーマスティッチング
import { stitchSchemas } from '@graphql-tools/stitch';

const userSchema = makeExecutableSchema({ typeDefs: userTypeDefs, resolvers: userResolvers });
const postSchema = makeExecutableSchema({ typeDefs: postTypeDefs, resolvers: postResolvers });

const schema = stitchSchemas({
  subschemas: [
    { schema: userSchema },
    { schema: postSchema },
  ],
});
Apollo Federation
// User service
import { buildSubgraphSchema } from '@apollo/subgraph';

const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    name: String!
    email: String!
  }
`;

const resolvers = {
  User: {
    __resolveReference(user, { db }) {
      return db.users.findById(user.id);
    },
  },
};

const schema = buildSubgraphSchema({ typeDefs, resolvers });

// Post service
const typeDefs = gql`
  extend type User @key(fields: "id") {
    id: ID! @external
    posts: [Post!]!
  }

  type Post @key(fields: "id") {
    id: ID!
    title: String!
    authorId: ID!
  }
`;

// Gateway
import { ApolloGateway } from '@apollo/gateway';

const gateway = new ApolloGateway({
  supergraphSdl: readFileSync('./supergraph.graphql', 'utf-8'),
});

const server = new ApolloServer({ gateway });
レート制限
import { GraphQLRateLimitDirective } from 'graphql-rate-limit-directive';

const typeDefs = gql`
  directive @rateLimit(
    limit: Int = 10
    duration: Int = 60
  ) on FIELD_DEFINITION

  type Query {
    users: [User!]! @rateLimit(limit: 100, duration: 60)
    search(query: String!): [Result!]! @rateLimit(limit: 10, duration: 60)
  }
`;

let schema = makeExecutableSchema({ typeDefs, resolvers });
schema = GraphQLRateLimitDirective()(schema);
モニタリング(Apollo Studio)
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    ApolloServerPluginUsageReporting({
      sendVariableValues: { all: true },
      sendHeaders: { all: true },
    }),
  ],
});

フレームワークとの統合

Next.js App Router
// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server';
import { startServerAndCreateNextHandler } from '@as-integrations/next';

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const handler = startServerAndCreateNextHandler(server);

export { handler as GET, handler as POST };
Apollo Client を使った Next.js(SSR)
// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { registerApolloClient } from '@apollo/experimental-nextjs-app-support/rsc';

export const { getClient } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: 'http://localhost:4000/graphql',
    }),
  });
});

// app/users/page.tsx
import { getClient } from '@/lib/apollo-client';
import { gql } from '@apollo/client';

export default async function UsersPage() {
  const { data } = await getClient().query({
    query: gql`
      query GetUsers {
        users {
          id
          name
        }
      }
    `,
  });

  return (
    <div>
      {data.users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}
FastAPI + Strawberry
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

app = FastAPI()

graphql_app = GraphQLRouter(schema)
app.include_router(graphql_app, prefix="/graphql")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

REST および tRPC との比較

GraphQL と REST の比較
特徴GraphQLREST
エンドポイント単一のエンドポイント複数のエンドポイント
データの取得クライアントがフィールドを指定サーバーがレスポンスを決定
過剰なデータ取得なしあり(余分なフィールド)
不足するデータ取得なしあり(複数のリクエスト)
バージョニング不要必要(v1、v2)
キャッシュ複雑簡単(HTTP キャッシュ)
型システム組み込み外部(OpenAPI)
リアルタイムサブスクリプションSSE/WebSocket
GraphQL と tRPC の比較
特徴GraphQLtRPC
型安全性コード生成が必要TypeScript ネイティブ
言語サポート任意TypeScript のみ
クライアントとサーバーの結合疎結合密結合
スキーマSDL が必要コードから推論
学習曲線急緩やか
ツール豊富拡大中
ユースケースパブリック API、モバイルフルスタック TypeScript

移行戦略

REST から GraphQL への段階的な移行
// 1. Wrap existing REST endpoints
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const response = await fetch(`https://api.example.com/users/${id}`);
      return response.json();
    },
  },
};

// 2. Add GraphQL layer alongside REST
app.use('/api/rest', restRouter);
app.use('/graphql', graphqlMiddleware);

// 3. Migrate clients incrementally
// 4. Deprecate REST endpoints when ready
既存のアプリへの GraphQL の追加
// Express + GraphQL
import express from 'express';
import { expressMiddleware } from '@apollo/server/express4';

const app = express();

// Existing routes
app.use('/api', existingApiRouter);

// Add GraphQL
app.use('/graphql', express.json(), expressMiddleware(server));

ベストプラクティス

スキーマ設計
  • 意味の明確なフィールド名を使います(created_at ではなく createdAt)
  • 汎用的な JSON よりも具体的な型を優先します
  • 固定された値の集合には enum を使います
  • データベースの構造ではなく、クライアントのユースケースに合わせて設計します
  • 複雑なミューテーションには入力型を使います
  • リストにはページネーションを実装します
  • コネクションには Relay 仕様に従います
リゾルバーパターン
  • リゾルバーは薄く保ち、サービス層に処理を委譲します
  • すべてのデータベース取得に DataLoader を使います
  • データベース層ではなく、リゾルバーで入力を検証します
  • 例外だけでなく、ペイロードでエラーを返します
  • 共有の依存関係(db、auth、loaders)にはコンテキストを使います
エラー処理
  • エラーコードを持つカスタムエラー型を使います
  • ミューテーションではフィールド単位のエラーを返します
  • サーバー側でエラーを記録し、クライアント向けには情報を無害化します
  • 部分的な結果を返せるよう、null 許容フィールドを使います
  • 内部実装の詳細を公開しないでください
パフォーマンス
  • N+1 を防ぐため、常に DataLoader を使います
  • クエリの複雑度に上限を設けます
  • 頻繁にアクセスされるデータをキャッシュします
  • 本番環境では永続化クエリを使います
  • 遅いクエリを監視して最適化します
  • 可能な場合はミューテーションをバッチ処理します
セキュリティ
  • 認証と認可を実装します
  • すべての入力を検証します
  • クエリの深さを制限します
  • ユーザーごとのレート制限を実装します
  • 本番環境ではイントロスペクションを無効にします(任意)
  • エラーメッセージを無害化します
テスト
  • リゾルバーを個別にテストします
  • 外部依存関係をモックします
  • エラー条件をテストします
  • 重要なフローを統合テストします
  • 実際のクライアントを使って E2E テストを行います
ドキュメント
  • フィールドの説明を明確に記述します
  • @deprecated を使って非推奨であることを記録します
  • スキーマのコメントに使用例を記載します
  • スキーマのドキュメントを最新の状態に保ちます

概要

GraphQL は、強い型付け、効率的なデータ取得、優れた開発者体験を備えた、強力で柔軟な API レイヤーを提供します。主な利点は次のとおりです。

  • クライアントによる制御:必要なデータだけを正確に取得できます
  • 型安全性:イントロスペクションを備えたスキーマファースト設計です
  • 単一のエンドポイント:API の表面を簡素化できます
  • リアルタイム:サブスクリプションを標準でサポートします
  • ツール群:優れたエコシステム(Apollo、Relay、コード生成)があります

考慮すべきトレードオフ:

  • 単純な CRUD では REST より複雑です
  • キャッシュには HTTP キャッシュより慎重な検討が必要です
  • GraphQL に慣れていないチームには学習コストがかかります
  • クエリの複雑さがパフォーマンスに影響する場合があります

最適な用途:

  • 帯域幅の効率性が求められるモバイルアプリ
  • 多様なデータニーズを持つ複雑なフロントエンド
  • マイクロサービスの集約
  • リアルタイムアプリケーション
  • マルチプラットフォームのクライアント(Web、モバイル、IoT)

まずはシンプルに:

  1. 中核となるエンティティのスキーマを定義します
  2. DataLoader を使ってリゾルバーを記述します
  3. 認証/認可を追加します
  4. エラー処理を実装します
  5. キャッシュで最適化します
  6. 必要に応じてサブスクリプションを追加します
  7. 監視して改善を続けます

API の柔軟性と開発者体験を重視する場合、GraphQL はその力を発揮します。TypeScript のコード生成と組み合わせることで、データベースから UI までエンドツーエンドの型安全性を実現できます。

コメント