GraphQL完全実践ガイド〜Schema設計・Apollo Server 4・Apollo Client・urql・Codegen・Federation【2026年版】〜

「GraphQL の Schema・Query・Mutation・Subscription を実コードで一気に把握したい」「Apollo Server 4 / Apollo Client / urql / GraphQL Codegen の最新セットアップを知りたい」「N+1 を DataLoader で解消したいけど書き方が分からない」「tRPC との違いを実装レベルで理解したい」。そんな声に応える完全実践ガイドです。本記事は GraphQL.js 16 + Apollo Server 4 + Apollo Client 3.9 + urql 4 + GraphQL Codegen 5 + Node.js 22 LTS + TypeScript を前提に、SDL の書き方から本番運用までを 50 個以上のコピペで動くコードで解説します。Pothos / Nexus による Code First、Federation、cursor-based pagination、Subscription、File Upload、認証/認可、tRPC との比較まで網羅します。

この記事を最後まで読むと身につくこと

  • SDL(Schema Definition Language)で Query / Mutation / Subscription / 全 6 種類の型を書く全コード
  • Apollo Server 4 の最新セットアップ・context・resolver chain・error handling
  • DataLoader による N+1 解消、cursor-based pagination、Subscription(WebSocket)、File Upload
  • Apollo Federation で複数サブグラフを 1 つのスーパーグラフに統合する手順
  • Pothos / Nexus による Code First スタイル(Schema First との比較)
  • Apollo Client / urql / Relay の使い分け、GraphQL Codegen で完全型付き hooks 生成
  • tRPC との明確な比較と選定基準、テスト戦略、本番運用のチューニング
  1. 1. GraphQL の概要と REST との比較
    1. 1.1 GraphQL とは何か
    2. 1.2 REST との根本的な違い
    3. 1.3 GraphQL の 3 つの操作タイプ
    4. 1.4 環境セットアップ(Node.js 22 LTS + TypeScript)
  2. 2. SDL(Schema Definition Language)完全解剖
    1. 2.1 最小の schema.graphql
    2. 2.2 scalar 型(組み込み 5 種類とカスタム)
    3. 2.3 object 型・field 引数
    4. 2.4 enum 型
    5. 2.5 interface 型(共通フィールドを宣言)
    6. 2.6 union 型(複数の object のどれかを返す)
    7. 2.7 input 型(Mutation の引数まとめ)
    8. 2.8 ルート型 3 つ(Query / Mutation / Subscription)
  3. 3. Apollo Server 4 で実装する完全な GraphQL サーバ
    1. 3.1 必要パッケージ
    2. 3.2 最小のサーバ(standalone)
    3. 3.3 schema をファイル分割して読み込む
    4. 3.4 Express 上に統合(REST と同居)
    5. 3.5 context にユーザ情報・DB クライアントを載せる
    6. 3.6 resolver の基本シグネチャ(4 引数)
    7. 3.7 関連フィールドの resolver chain
  4. 4. DataLoader で N+1 問題を完全に解消する
    1. 4.1 N+1 が発生する典型コード
    2. 4.2 DataLoader の導入
    3. 4.3 context にローダを 1 リクエスト毎に再生成
    4. 4.4 resolver でローダを使う
    5. 4.5 動作確認: 1 クエリにまとまる
  5. 5. fragment / variables / directives とクライアント側の構文
    1. 5.1 fragment(クエリの部品化)
    2. 5.2 variables(クエリへの安全な値渡し)
    3. 5.3 directives(@include / @skip / @deprecated)
    4. 5.4 inline fragment(union / interface の分岐)
  6. 6. 認証・認可・エラーハンドリング
    1. 6.1 JWT 検証(jose を使う最新例)
    2. 6.2 GraphQLError でクリーンに失敗を返す
    3. 6.3 認可ヘルパで resolver を守る
    4. 6.4 Apollo Server 4 の formatError でログを整える
  7. 7. Subscription・File Upload・Pagination
    1. 7.1 Subscription を WebSocket(graphql-ws)で実装
    2. 7.2 PubSub で値を流す
    3. 7.3 File Upload(graphql-upload)
    4. 7.4 cursor-based pagination(Relay 仕様)
  8. 8. Schema First vs Code First(Pothos / Nexus)
    1. 8.1 Pothos で Code First スタイル
    2. 8.2 Pothos + Prisma plugin(完全型付け)
    3. 8.3 Nexus による Code First
    4. 8.4 Schema First vs Code First の選定
  9. 9. Apollo Client / urql / Relay とクライアント実装
    1. 9.1 Apollo Client セットアップ
    2. 9.2 useQuery / useMutation
    3. 9.3 cache policy(typePolicies で fields を上書き)
    4. 9.4 optimistic UI(送信前に画面を即時更新)
    5. 9.5 urql セットアップ(軽量・キャッシュ柔軟)
    6. 9.6 urql の Graphcache(正規化キャッシュ)
    7. 9.7 Relay の特徴(Connection / @refetchable)
  10. 10. GraphQL Codegen で完全型付き hooks を生成する
    1. 10.1 必要パッケージ
    2. 10.2 codegen.ts
    3. 10.3 typed-document-node で型付けされた gql タグ
    4. 10.4 fragment masking で漏れた依存を防ぐ
  11. 11. Apollo Federation でマイクロサービスを統合
    1. 11.1 サブグラフ用パッケージ
    2. 11.2 サブグラフ A(users)
    3. 11.3 サブグラフ B(posts)
    4. 11.4 supergraph(rover で合成)
  12. 12. GraphQL Yoga / Mesh / tRPC との比較と選定
    1. 12.1 GraphQL Yoga(軽量で Edge にも乗る)
    2. 12.2 GraphQL Mesh(既存 REST/gRPC/OpenAPI を GraphQL に変換)
    3. 12.3 tRPC との比較(Hono や NestJS ユーザに重要)
  13. 13. テスト戦略・本番運用・パフォーマンス
    1. 13.1 resolver 単体テスト(Vitest + executor)
    2. 13.2 統合テスト(supertest + Express)
    3. 13.3 クエリの永続化(Persisted Queries)で帯域を削る
    4. 13.4 depth limit / complexity limit で悪意のクエリを防ぐ
    5. 13.5 introspection を本番で無効化
    6. 13.6 Apollo Sandbox / Studio で監視
    7. 13.7 主要 N+1 / パフォーマンス Tips
  14. 14. まとめ・関連サービス・キャリア
    1. 関連記事

1. GraphQL の概要と REST との比較

1.1 GraphQL とは何か

GraphQL は Facebook(現 Meta)が 2012 年に内製し 2015 年に OSS 化した API クエリ言語と実行ランタイムの仕様です。クライアントが必要なフィールドだけを宣言的に要求し、サーバが定義したスキーマに従ってデータを返します。エンドポイントは原則 /graphql 1 本で、HTTP メソッドは POST(または GET)を使います。

1.2 REST との根本的な違い

REST が「リソースごとに URL を切り、必要なデータを得るために複数回叩く」設計なのに対し、GraphQL は「1 リクエストでクライアントが必要な木構造をまるごと取得」します。Over-fetching(余計なフィールドが返る)と Under-fetching(欲しい関連データのために何度も叩く)を同時に解決するのが最大の動機です。

# REST: 関連データを取りに行くと何回もリクエストが発生する
GET /users/1                 -> { id, name }
GET /users/1/posts           -> [{ id, title }, ...]
GET /posts/10/comments       -> [{ id, body }, ...]
# GraphQL: 1 リクエストで木構造をまるごと取得
query {
  user(id: "1") {
    id
    name
    posts {
      id
      title
      comments { id body }
    }
  }
}

1.3 GraphQL の 3 つの操作タイプ

GraphQL の操作は Query(読み取り)Mutation(書き込み)Subscription(リアルタイム購読)の 3 種類だけです。REST の 6〜7 種類の HTTP メソッドや独自エンドポイントを覚える必要がありません。

# Query: 取得
query GetUser { user(id: "1") { id name } }

# Mutation: 変更
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) { id title }
}

# Subscription: WebSocket でリアルタイム購読
subscription OnNewComment($postId: ID!) {
  commentAdded(postId: $postId) { id body }
}

1.4 環境セットアップ(Node.js 22 LTS + TypeScript)

node -v
# v22.11.0
npm -v
# 10.9.0

mkdir gql-app && cd gql-app
npm init -y
npm i -D typescript @types/node tsx
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext 
  --esModuleInterop --strict --outDir dist

2. SDL(Schema Definition Language)完全解剖

2.1 最小の schema.graphql

# schema.graphql
type Query {
  hello: String!
}

末尾の !non-null(必ず値が返る)を表します。! なしは nullable で、解決失敗時に null が入ります。配列も [Post!]! のように二重に ! を付けられ、「配列自体が null でない・要素も null でない」を表現できます。

2.2 scalar 型(組み込み 5 種類とカスタム)

# 組み込みの 5 種類
# Int / Float / String / Boolean / ID

# カスタム scalar も宣言できる(実装は resolver で行う)
scalar DateTime
scalar JSON
scalar EmailAddress

type Event {
  id: ID!
  startsAt: DateTime!
  metadata: JSON
  organizer: EmailAddress!
}

2.3 object 型・field 引数

type User {
  id: ID!
  name: String!
  email: String!
  # field に引数を持たせられる(クライアント側で動的に切替)
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
}

2.4 enum 型

enum Role {
  GUEST
  USER
  ADMIN
}

extend type User {
  role: Role!
}

2.5 interface 型(共通フィールドを宣言)

interface Node {
  id: ID!
}

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

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

2.6 union 型(複数の object のどれかを返す)

union SearchResult = User | Post | Comment

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

2.7 input 型(Mutation の引数まとめ)

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!] = []
}

input UpdatePostInput {
  id: ID!
  title: String
  body: String
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

2.8 ルート型 3 つ(Query / Mutation / Subscription)

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

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

3. Apollo Server 4 で実装する完全な GraphQL サーバ

3.1 必要パッケージ

npm i @apollo/server graphql
npm i -D typescript tsx @types/node

3.2 最小のサーバ(standalone)

// src/index.ts
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

const typeDefs = `#graphql
  type Query {
    hello(name: String): String!
  }
`;

const resolvers = {
  Query: {
    hello: (_p: unknown, args: { name?: string }) =>
      `Hello, ${args.name ?? "world"}!`,
  },
};

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

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
console.log(`🚀 Server ready at ${url}`);
# 起動 → http://localhost:4000 で Apollo Sandbox が開く
npx tsx src/index.ts

3.3 schema をファイル分割して読み込む

// src/schema/index.ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
export const typeDefs = readFileSync(
  join(__dirname, "schema.graphql"),
  "utf-8",
);

3.4 Express 上に統合(REST と同居)

npm i express cors body-parser @as-integrations/express5
// src/server.ts
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express5";
import { typeDefs } from "./schema/index.js";
import { resolvers } from "./resolvers/index.js";

const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();

app.use(
  "/graphql",
  cors<cors.CorsRequest>(),
  bodyParser.json({ limit: "1mb" }),
  expressMiddleware(server, {
    context: async ({ req }) => ({ token: req.headers.authorization }),
  }),
);

app.get("/healthz", (_req, res) => res.send("ok"));

app.listen(4000, () => console.log("http://localhost:4000/graphql"));

3.5 context にユーザ情報・DB クライアントを載せる

// src/context.ts
import type { PrismaClient } from "@prisma/client";

export interface GqlContext {
  prisma: PrismaClient;
  user: { id: string; role: "GUEST" | "USER" | "ADMIN" } | null;
}

export async function buildContext(
  prisma: PrismaClient,
  authHeader: string | undefined,
): Promise<GqlContext> {
  if (!authHeader) return { prisma, user: null };
  const token = authHeader.replace(/^Bearers+/i, "");
  const user = await verifyToken(token); // 後述
  return { prisma, user };
}

3.6 resolver の基本シグネチャ(4 引数)

// (parent, args, context, info) の 4 引数
type Resolver<P, A, R> = (
  parent: P,
  args: A,
  ctx: GqlContext,
  info: any,
) => R | Promise<R>;

const resolvers = {
  Query: {
    user: (async (_p, args: { id: string }, ctx) => {
      return ctx.prisma.user.findUnique({ where: { id: args.id } });
    }) satisfies Resolver<unknown, { id: string }, unknown>,
  },
};

3.7 関連フィールドの resolver chain

// User.posts は親オブジェクトの id を使って Post を引く
export const resolvers = {
  Query: {
    user: (_p, { id }, ctx) => ctx.prisma.user.findUnique({ where: { id } }),
  },
  User: {
    posts: (parent: { id: string }, _a, ctx) =>
      ctx.prisma.post.findMany({ where: { authorId: parent.id } }),
  },
  Post: {
    author: (parent: { authorId: string }, _a, ctx) =>
      ctx.prisma.user.findUnique({ where: { id: parent.authorId } }),
  },
};

4. DataLoader で N+1 問題を完全に解消する

4.1 N+1 が発生する典型コード

query {
  posts { id title author { id name } }
}

このクエリで Post.author の resolver が記事ごとに SELECT * FROM user WHERE id=? を発行すると、1(posts)+ N(authors) クエリになります。100 件の記事で 101 クエリが飛び、レイテンシが致命的に悪化します。

4.2 DataLoader の導入

npm i dataloader
// src/loaders/userLoader.ts
import DataLoader from "dataloader";
import type { PrismaClient, User } from "@prisma/client";

export function createUserLoader(prisma: PrismaClient) {
  return new DataLoader<string, User | null>(async (ids) => {
    const rows = await prisma.user.findMany({
      where: { id: { in: [...ids] } },
    });
    const byId = new Map(rows.map((r) => [r.id, r]));
    // 必ず ids と同じ順序・長さで返すのがルール
    return ids.map((id) => byId.get(id) ?? null);
  });
}

4.3 context にローダを 1 リクエスト毎に再生成

// src/context.ts(続き)
import { createUserLoader } from "./loaders/userLoader.js";

export interface GqlContext {
  prisma: PrismaClient;
  user: AuthUser | null;
  loaders: {
    user: ReturnType<typeof createUserLoader>;
  };
}

export async function buildContext(prisma: PrismaClient, auth: string | undefined): Promise<GqlContext> {
  return {
    prisma,
    user: auth ? await verifyToken(auth) : null,
    // ⚠ 必ずリクエストごとに新規作成(キャッシュ漏洩を防ぐ)
    loaders: { user: createUserLoader(prisma) },
  };
}

4.4 resolver でローダを使う

export const Post = {
  author: (parent: { authorId: string }, _a, ctx: GqlContext) =>
    ctx.loaders.user.load(parent.authorId),
};

4.5 動作確認: 1 クエリにまとまる

# Prisma のクエリログを ON にして確認
DEBUG="prisma:query" npx tsx src/server.ts
# 100 件の Post に対して User SELECT が IN (...) で 1 回だけになる

5. fragment / variables / directives とクライアント側の構文

5.1 fragment(クエリの部品化)

fragment UserCore on User {
  id
  name
  email
}

query {
  me { ...UserCore role }
  users(limit: 5) { ...UserCore }
}

5.2 variables(クエリへの安全な値渡し)

query GetUser($id: ID!) {
  user(id: $id) { id name }
}
# cURL でリクエスト
curl -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -d '{"query":"query($id:ID!){user(id:$id){id name}}","variables":{"id":"1"}}'

5.3 directives(@include / @skip / @deprecated)

query ($withPosts: Boolean!) {
  me {
    id
    name
    posts @include(if: $withPosts) { id title }
    legacyHandle @skip(if: true)
  }
}
# スキーマ側で非推奨にする
type User {
  id: ID!
  name: String!
  legacyHandle: String @deprecated(reason: "Use name instead.")
}

5.4 inline fragment(union / interface の分岐)

query {
  search(keyword: "hi") {
    __typename
    ... on User { id name }
    ... on Post { id title }
  }
}

6. 認証・認可・エラーハンドリング

6.1 JWT 検証(jose を使う最新例)

npm i jose
// src/auth.ts
import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, secret);
  return { id: String(payload.sub), role: String(payload.role) as "USER" | "ADMIN" };
}

6.2 GraphQLError でクリーンに失敗を返す

import { GraphQLError } from "graphql";

export const Query = {
  me: (_p, _a, ctx: GqlContext) => {
    if (!ctx.user) {
      throw new GraphQLError("Not authenticated", {
        extensions: { code: "UNAUTHENTICATED", http: { status: 401 } },
      });
    }
    return ctx.prisma.user.findUnique({ where: { id: ctx.user.id } });
  },
};

6.3 認可ヘルパで resolver を守る

// src/auth/guard.ts
import { GraphQLError } from "graphql";
import type { GqlContext } from "../context.js";

export function requireRole(ctx: GqlContext, allowed: Array<"USER" | "ADMIN">) {
  if (!ctx.user) throw new GraphQLError("Unauthenticated", {
    extensions: { code: "UNAUTHENTICATED" },
  });
  if (!allowed.includes(ctx.user.role)) {
    throw new GraphQLError("Forbidden", {
      extensions: { code: "FORBIDDEN" },
    });
  }
}
// 使い方
export const Mutation = {
  deletePost: async (_p, { id }: { id: string }, ctx: GqlContext) => {
    requireRole(ctx, ["ADMIN"]);
    await ctx.prisma.post.delete({ where: { id } });
    return true;
  },
};

6.4 Apollo Server 4 の formatError でログを整える

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formatted, error) => {
    // 本番ではスタックを返さない
    if (process.env.NODE_ENV === "production") {
      const { extensions, message } = formatted;
      return { message, extensions: { code: extensions?.code ?? "INTERNAL" } };
    }
    console.error(error);
    return formatted;
  },
});

7. Subscription・File Upload・Pagination

7.1 Subscription を WebSocket(graphql-ws)で実装

npm i graphql-ws ws @graphql-tools/schema
// src/subscriptions.ts
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { useServer } from "graphql-ws/lib/use/ws";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express5";
import express from "express";
import bodyParser from "body-parser";
import { typeDefs } from "./schema/index.js";
import { resolvers } from "./resolvers/index.js";

const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: "/graphql" });

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

const apollo = new ApolloServer({
  schema,
  plugins: [{
    async serverWillStart() {
      return { async drainServer() { await wsCleanup.dispose(); } };
    },
  }],
});
await apollo.start();

app.use("/graphql", bodyParser.json(), expressMiddleware(apollo));
httpServer.listen(4000);

7.2 PubSub で値を流す

npm i graphql-subscriptions
// src/pubsub.ts
import { PubSub } from "graphql-subscriptions";
export const pubsub = new PubSub();
export const COMMENT_ADDED = "COMMENT_ADDED";
// resolver
import { pubsub, COMMENT_ADDED } from "../pubsub.js";

export const Mutation = {
  addComment: async (_p, { postId, body }: { postId: string; body: string }, ctx: GqlContext) => {
    const comment = await ctx.prisma.comment.create({ data: { postId, body, userId: ctx.user!.id } });
    pubsub.publish(COMMENT_ADDED, { commentAdded: comment, postId });
    return comment;
  },
};

export const Subscription = {
  commentAdded: {
    // postId が一致するイベントだけを流す
    subscribe: (_p, { postId }: { postId: string }) => ({
      [Symbol.asyncIterator]: () => pubsub.asyncIterator(COMMENT_ADDED),
    }),
    resolve: (payload: any, args: { postId: string }) =>
      payload.postId === args.postId ? payload.commentAdded : null,
  },
};

7.3 File Upload(graphql-upload)

npm i graphql-upload
scalar Upload

type Mutation {
  uploadAvatar(file: Upload!): String!
}
import { GraphQLUpload, graphqlUploadExpress } from "graphql-upload";
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";

app.use("/graphql", graphqlUploadExpress({ maxFileSize: 5_000_000, maxFiles: 1 }));

export const resolvers = {
  Upload: GraphQLUpload,
  Mutation: {
    uploadAvatar: async (_p, { file }: any) => {
      const { createReadStream, filename } = await file;
      const out = `./uploads/${Date.now()}-${filename}`;
      await pipeline(createReadStream(), createWriteStream(out));
      return out;
    },
  },
};

7.4 cursor-based pagination(Relay 仕様)

type PageInfo {
  endCursor: String
  hasNextPage: Boolean!
}

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

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

extend type Query {
  posts(first: Int = 20, after: String): PostConnection!
}
export const Query = {
  posts: async (_p, args: { first?: number; after?: string }, ctx: GqlContext) => {
    const first = Math.min(args.first ?? 20, 100);
    const rows = await ctx.prisma.post.findMany({
      take: first + 1,
      ...(args.after ? { cursor: { id: args.after }, skip: 1 } : {}),
      orderBy: { id: "asc" },
    });
    const hasNextPage = rows.length > first;
    const nodes = hasNextPage ? rows.slice(0, -1) : rows;
    return {
      edges: nodes.map((n) => ({ cursor: n.id, node: n })),
      pageInfo: { endCursor: nodes.at(-1)?.id ?? null, hasNextPage },
    };
  },
};

8. Schema First vs Code First(Pothos / Nexus)

8.1 Pothos で Code First スタイル

npm i @pothos/core
// src/pothos/builder.ts
import SchemaBuilder from "@pothos/core";
export const builder = new SchemaBuilder({});
// src/pothos/user.ts
import { builder } from "./builder.js";

builder.objectType("User", {
  fields: (t) => ({
    id: t.exposeID("id"),
    name: t.exposeString("name"),
    email: t.exposeString("email"),
  }),
});

builder.queryType({
  fields: (t) => ({
    me: t.field({
      type: "User",
      nullable: true,
      resolve: (_p, _a, ctx: { user?: { id: string; name: string; email: string } }) =>
        ctx.user ?? null,
    }),
  }),
});

export const schema = builder.toSchema();

8.2 Pothos + Prisma plugin(完全型付け)

npm i @pothos/plugin-prisma
import PrismaPlugin from "@pothos/plugin-prisma";
import type PrismaTypes from "@pothos/plugin-prisma/generated";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();
const builder = new SchemaBuilder<{ PrismaTypes: PrismaTypes }>({
  plugins: [PrismaPlugin],
  prisma: { client: prisma },
});

builder.prismaObject("User", {
  fields: (t) => ({
    id: t.exposeID("id"),
    name: t.exposeString("name"),
    posts: t.relation("posts"), // N+1 を自動で解消
  }),
});

8.3 Nexus による Code First

npm i nexus graphql
// src/nexus/schema.ts
import { makeSchema, objectType, queryType, stringArg } from "nexus";

const User = objectType({
  name: "User",
  definition(t) {
    t.id("id");
    t.string("name");
  },
});

const Query = queryType({
  definition(t) {
    t.field("user", {
      type: User,
      args: { id: stringArg() },
      resolve: (_p, args, ctx) => ctx.prisma.user.findUnique({ where: { id: args.id! } }),
    });
  },
});

export const schema = makeSchema({ types: [User, Query] });

8.4 Schema First vs Code First の選定

Schema First を選ぶケース

  • フロントエンドとサーバを別チームで分業し、SDL を契約書として扱いたい
  • クライアント側で Codegen を最優先したい(SDL がそのまま入力になる)

Code First(Pothos / Nexus)を選ぶケース

  • Prisma / TypeScript の型を そのまま GraphQL 型に流用したい
  • 動的にスキーマを組み立てたい(ロールでフィールドを出し分けるなど)

9. Apollo Client / urql / Relay とクライアント実装

9.1 Apollo Client セットアップ

npm i @apollo/client graphql
// src/apollo.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const httpLink = new HttpLink({ uri: "/graphql" });
const authLink = setContext((_op, { headers }) => {
  const token = localStorage.getItem("token");
  return { headers: { ...headers, authorization: token ? `Bearer ${token}` : "" } };
});

export const apolloClient = new ApolloClient({
  link: from([authLink, httpLink]),
  cache: new InMemoryCache(),
});

9.2 useQuery / useMutation

// src/pages/UserPage.tsx
import { gql, useQuery, useMutation } from "@apollo/client";

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

const UPDATE_NAME = gql`
  mutation UpdateName($id: ID!, $name: String!) {
    updateUser(id: $id, name: $name) { id name }
  }
`;

export function UserPage({ id }: { id: string }) {
  const { data, loading, error } = useQuery(GET_USER, { variables: { id } });
  const [updateName] = useMutation(UPDATE_NAME);

  if (loading) return <p>loading...</p>;
  if (error) return <p>{error.message}</p>;
  return (
    <button onClick={() => updateName({ variables: { id, name: "Alice" } })}>
      {data.user.name}
    </button>
  );
}

9.3 cache policy(typePolicies で fields を上書き)

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          // cursor 連結を自然に行う
          keyArgs: false,
          merge(existing = { edges: [] }, incoming) {
            return { ...incoming, edges: [...existing.edges, ...incoming.edges] };
          },
        },
      },
    },
  },
});

9.4 optimistic UI(送信前に画面を即時更新)

await updateName({
  variables: { id, name: "Alice" },
  optimisticResponse: {
    updateUser: { __typename: "User", id, name: "Alice" },
  },
});

9.5 urql セットアップ(軽量・キャッシュ柔軟)

npm i urql graphql
// src/urql.ts
import { createClient, fetchExchange, cacheExchange } from "urql";

export const urqlClient = createClient({
  url: "/graphql",
  exchanges: [cacheExchange, fetchExchange],
  fetchOptions: () => ({
    headers: { authorization: `Bearer ${localStorage.getItem("token") ?? ""}` },
  }),
});
// src/pages/UrqlUser.tsx
import { useQuery } from "urql";

const GET_USER = `
  query ($id: ID!) { user(id: $id) { id name } }
`;

export function UrqlUser({ id }: { id: string }) {
  const [{ data, fetching, error }] = useQuery({ query: GET_USER, variables: { id } });
  if (fetching) return <p>loading...</p>;
  if (error) return <p>{error.message}</p>;
  return <p>{data.user.name}</p>;
}

9.6 urql の Graphcache(正規化キャッシュ)

npm i @urql/exchange-graphcache
import { cacheExchange } from "@urql/exchange-graphcache";

const cache = cacheExchange({
  keys: { Post: (p) => p.id as string },
  updates: {
    Mutation: {
      addComment: (result, args, cache) => {
        cache.invalidate({ __typename: "Post", id: (args as any).postId });
      },
    },
  },
});

9.7 Relay の特徴(Connection / @refetchable)

Relay は Meta が運用する 大規模 SPA 向けの GraphQL クライアントです。Connection(cursor pagination の仕様)・Fragment Container・@refetchable / @paginated ディレクティブで「各コンポーネントが必要なフィールドだけを宣言」する設計を徹底します。学習コストは高めですが、巨大アプリでは Apollo Client より遥かに型・キャッシュが堅牢になります。

10. GraphQL Codegen で完全型付き hooks を生成する

10.1 必要パッケージ

npm i -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-typed-document-node/core

10.2 codegen.ts

// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "http://localhost:4000/graphql",
  documents: ["src/**/*.ts", "src/**/*.tsx"],
  generates: {
    "./src/gql/": {
      preset: "client",
      config: { useTypeImports: true },
    },
  },
};
export default config;
npx graphql-codegen --watch

10.3 typed-document-node で型付けされた gql タグ

// src/components/UserCard.tsx
import { useQuery } from "@apollo/client";
import { graphql } from "../gql"; // codegen が生成

const GetUserDoc = graphql(`
  query GetUserTyped($id: ID!) {
    user(id: $id) { id name email }
  }
`);

export function UserCard({ id }: { id: string }) {
  // data.user は完全に型付け、id 引数も必須として型推論される
  const { data } = useQuery(GetUserDoc, { variables: { id } });
  return <p>{data?.user?.name}</p>;
}

10.4 fragment masking で漏れた依存を防ぐ

import { useFragment, graphql, FragmentType } from "../gql";

const UserCoreFragment = graphql(`
  fragment UserCore on User { id name }
`);

export function UserName({ user }: { user: FragmentType<typeof UserCoreFragment> }) {
  const u = useFragment(UserCoreFragment, user);
  return <span>{u.name}</span>;
}

11. Apollo Federation でマイクロサービスを統合

11.1 サブグラフ用パッケージ

npm i @apollo/subgraph

11.2 サブグラフ A(users)

// services/users/index.ts
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { buildSubgraphSchema } from "@apollo/subgraph";
import gql from "graphql-tag";

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

const resolvers = {
  Query: { me: () => ({ id: "u1", name: "Alice" }) },
  User: { __resolveReference: (ref: { id: string }) => ({ id: ref.id, name: "Alice" }) },
};

const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }) });
await startStandaloneServer(server, { listen: { port: 4001 } });

11.3 サブグラフ B(posts)

const typeDefs = gql`
  type Post @key(fields: "id") {
    id: ID!
    title: String!
    author: User!
  }
  extend type User @key(fields: "id") {
    id: ID! @external
    posts: [Post!]!
  }
  type Query { posts: [Post!]! }
`;

11.4 supergraph(rover で合成)

# Rover CLI を導入してスーパーグラフを合成
curl -sSL https://rover.apollo.dev/nix/latest | sh
rover supergraph compose --config ./supergraph.yaml > supergraph.graphql
# supergraph.yaml
federation_version: =2.7.0
subgraphs:
  users:
    routing_url: http://localhost:4001
    schema: { subgraph_url: http://localhost:4001 }
  posts:
    routing_url: http://localhost:4002
    schema: { subgraph_url: http://localhost:4002 }

12. GraphQL Yoga / Mesh / tRPC との比較と選定

12.1 GraphQL Yoga(軽量で Edge にも乗る)

npm i graphql-yoga
// Yoga は Web Standard(Request/Response)で書ける
import { createYoga, createSchema } from "graphql-yoga";
import { createServer } from "node:http";

const yoga = createYoga({
  schema: createSchema({
    typeDefs: `type Query { hello: String! }`,
    resolvers: { Query: { hello: () => "yoga" } },
  }),
});

createServer(yoga).listen(4000);

12.2 GraphQL Mesh(既存 REST/gRPC/OpenAPI を GraphQL に変換)

npm i @graphql-mesh/cli @graphql-mesh/openapi
# .meshrc.yaml
sources:
  - name: PetStore
    handler:
      openapi:
        source: https://petstore3.swagger.io/api/v3/openapi.json
npx mesh dev
# → 既存 OpenAPI が GraphQL エンドポイントとして公開される

12.3 tRPC との比較(Hono や NestJS ユーザに重要)

tRPC は TypeScript monorepo 専用の RPC ライブラリで、サーバの procedure 型をクライアントが直接 import して使います。SDL も Codegen も不要で、TS の型情報がそのままワイヤープロトコルの「契約」になります。一方 GraphQL は 言語非依存・チーム/組織横断で API を提供できる仕様で、フロントエンドが Swift / Kotlin / Rust など TS 以外でも同じ恩恵を受けられます。

// tRPC の例(参考: TS monorepo 専用)
// server.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
  user: t.procedure.input((v) => v as { id: string }).query(({ input }) => ({ id: input.id, name: "Alice" })),
});
export type AppRouter = typeof appRouter;
// client.ts(同じリポジトリで型を import)
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./server.js";
const trpc = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/trpc" })] });
const u = await trpc.user.query({ id: "1" });
GraphQL を選ぶべきケース

  • サードパーティに API を公開、または社外チーム/別言語のクライアントが繋ぐ
  • iOS / Android / Web で 同じスキーマを共有したい
  • クライアント主導で取得フィールドを変えたい(BFF が要らなくなる)
  • Federation で複数チームのスキーマを 1 つに統合したい

tRPC を選ぶべきケース

  • TS monorepo で完結し、別言語クライアントが当面要らない
  • SDL や Codegen を運用したくない(TS の型をそのまま使いたい)
  • レイテンシ最小・最小依存を最優先するスタートアップの MVP

13. テスト戦略・本番運用・パフォーマンス

13.1 resolver 単体テスト(Vitest + executor)

npm i -D vitest @graphql-tools/executor
// tests/resolvers.test.ts
import { describe, it, expect } from "vitest";
import { execute, parse } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { typeDefs } from "../src/schema/index.js";
import { resolvers } from "../src/resolvers/index.js";

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

describe("Query.hello", () => {
  it("引数なしで world を返す", async () => {
    const res = await execute({
      schema,
      document: parse(`{ hello }`),
      contextValue: { prisma: {}, user: null, loaders: {} },
    });
    expect(res.data?.hello).toBe("Hello, world!");
  });
});

13.2 統合テスト(supertest + Express)

npm i -D supertest @types/supertest
// tests/server.test.ts
import request from "supertest";
import { app } from "../src/server.js";

it("POST /graphql で hello が取れる", async () => {
  const res = await request(app)
    .post("/graphql")
    .set("Content-Type", "application/json")
    .send({ query: "{ hello }" });
  expect(res.status).toBe(200);
  expect(res.body.data.hello).toBe("Hello, world!");
});

13.3 クエリの永続化(Persisted Queries)で帯域を削る

// Apollo Client 側で hash を送るだけ
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
import { sha256 } from "crypto-hash";

const linkChain = createPersistedQueryLink({ sha256 }).concat(new HttpLink({ uri: "/graphql" }));

13.4 depth limit / complexity limit で悪意のクエリを防ぐ

npm i graphql-depth-limit graphql-query-complexity
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-query-complexity";

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(10),
    createComplexityLimitRule(1000, {
      onCost: (cost) => console.log("query cost:", cost),
    }),
  ],
});

13.5 introspection を本番で無効化

const server = new ApolloServer({
  schema,
  introspection: process.env.NODE_ENV !== "production",
});

13.6 Apollo Sandbox / Studio で監視

// Apollo Studio に metrics を送る
const server = new ApolloServer({
  schema,
  plugins: [
    process.env.NODE_ENV === "production"
      ? require("@apollo/server/plugin/landingPage/default").ApolloServerPluginLandingPageProductionDefault({
          graphRef: "myorg-graph@current",
          footer: false,
        })
      : require("@apollo/server/plugin/landingPage/default").ApolloServerPluginLandingPageLocalDefault({ embed: true }),
  ],
});

13.7 主要 N+1 / パフォーマンス Tips

パフォーマンス最適化チェックリスト

  • 関連取得は DataLoader か Pothos Prisma plugin で必ずバッチ化
  • クエリ毎にローダを 新規生成(キャッシュ漏洩を防ぐ)
  • info.fieldNodes から 選択フィールドのみ SELECT(graphql-fields / graphql-parse-resolve-info)
  • 巨大なリストは cursor pagination + DataLoader の組み合わせ
  • Subscription は 水平スケール時に Redis PubSub(graphql-redis-subscriptions)へ切替
  • 本番では introspection を無効化 + depth/complexity 制限を必ず入れる

14. まとめ・関連サービス・キャリア

GraphQL の使い方を SDL 設計 → Apollo Server 4 → resolver chain → DataLoader → 認証/認可 → Subscription / Upload / Pagination → Code First(Pothos / Nexus)→ クライアント(Apollo Client / urql / Relay)→ Codegen → Federation → 周辺(Yoga / Mesh)→ tRPC との比較 → テスト → 本番運用 まで一気通貫で見てきました。GraphQL の本質は「クライアントが必要なフィールドだけを取り、サーバが型で保証する」こと。チーム規模が大きく、複数のクライアント(Web / iOS / Android / 外部企業)が同じ API を叩く環境ほど効きます。逆に、TS monorepo で完結する小〜中規模なら tRPC のほうが軽くて速い、というのも実装現場の正直な肌感です。Apollo Server 4 + Pothos + Prisma + GraphQL Codegen の組み合わせは、2026 年現在もっとも生産性が高い構成と言えます。

独学で詰まりやすい人は学習サービスで土台ごと一気に固める手も

GraphQL 単体ではなく、TypeScript・Node.js・SQL・REST/GraphQL・React を横断で身につけると現場で即戦力になります。短期集中で叩き込みたい人には以下のサービスが定番です。

  • テックアカデミー:Node.js / TypeScript / React を含む Web 開発カリキュラム(オンライン完結、現役エンジニアのメンタリング付き)
  • 侍エンジニア:マンツーマンで Apollo Server + Next.js の API を成果物にできる(GraphQL 案件への転職も視野)
  • DMM WEBCAMP:Web エンジニア転職を見据えた長期コース(バックエンド志望に強く、ポートフォリオ作成支援が手厚い)
  • レバテックキャリア:GraphQL / TypeScript / Node.js の実務求人と年収レンジを確認し、市場価値を客観的に把握できる

関連記事

コメント

タイトルとURLをコピーしました