「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. GraphQL の概要と REST との比較
- 2. SDL(Schema Definition Language)完全解剖
- 3. Apollo Server 4 で実装する完全な GraphQL サーバ
- 4. DataLoader で N+1 問題を完全に解消する
- 5. fragment / variables / directives とクライアント側の構文
- 6. 認証・認可・エラーハンドリング
- 7. Subscription・File Upload・Pagination
- 8. Schema First vs Code First(Pothos / Nexus)
- 9. Apollo Client / urql / Relay とクライアント実装
- 10. GraphQL Codegen で完全型付き hooks を生成する
- 11. Apollo Federation でマイクロサービスを統合
- 12. GraphQL Yoga / Mesh / tRPC との比較と選定
- 13. テスト戦略・本番運用・パフォーマンス
- 14. まとめ・関連サービス・キャリア
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 の選定
- フロントエンドとサーバを別チームで分業し、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" });
- サードパーティに 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 の実務求人と年収レンジを確認し、市場価値を客観的に把握できる

コメント