apollo-graphql-best-practices について
apollo-graphql-best-practicesは、Apollo ClientやApollo Serverを使うアプリを開発・保守するチーム向けのリファレンススキルです。スキーマやリゾルバー、キャッシュ、エラー処理、性能・セキュリティ、Relay形式のパターンを扱います。
主な機能
- 01Apollo Clientの設定とクエリ
- 02キャッシュポリシーとページネーション
- 03スキーマとリゾルバーの設計指針
- 04GraphQLのエラー処理
- 05性能とセキュリティの実践
- 06Relay IDとDataLoaderのパターン
活用シーン
- 01Apolloプロジェクトの開始
- 02GraphQLスキーマとリゾルバーのレビュー
- 03キャッシュとページネーションの調整
- 04N+1クエリとクエリ上のリスクへの対処
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ maximepzv/ skills --skill apollo-graphql-best-practicesインストール後、Claude Code で/apollo-graphql-best-practicesを入力して読み込まれたか確認します。
詳細
Apollo GraphQL のベストプラクティス
適用するタイミング
次の場合に、このスキルを使用してください。
- 新しい Apollo Client または Apollo Server のプロジェクトをセットアップするとき
- GraphQL クエリ、ミューテーション、またはサブスクリプションを記述するとき
useQuery、useMutation、またはuseLazyQueryを使った React コンポーネントを実装するとき- キャッシュポリシー、型ポリシー、またはページネーションを設定するとき
- GraphQL スキーマを設計したり、リゾルバーを記述したりするとき
- GraphQL またはネットワークのエラーを処理するとき
- GraphQL のパフォーマンス(N+1 クエリ、キャッシュ、バッチ処理)を最適化するとき
- 既存の Apollo GraphQL コードをレビューまたはリファクタリングするとき
Apollo Client
クライアントのセットアップ
InMemoryCache と適切な型ポリシーを使って ApolloClient を設定します。
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
const client = new ApolloClient({
link: new HttpLink({ uri: "/graphql" }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Define field policies for pagination, merging, etc.
},
},
// Custom key fields for entity identification
User: {
keyFields: ["email"], // Use email instead of id
},
},
}),
});
useQuery を使ったクエリ
import { useQuery, gql } from "@apollo/client";
const GET_DATA = gql`
query GetData($id: ID!) {
item(id: $id) {
id
name
}
}
`;
function Component({ id }: { id: string }) {
const { data, loading, error } = useQuery(GET_DATA, {
variables: { id },
fetchPolicy: "cache-first", // Default, use cache when available
});
if (loading) return <Loading />;
if (error) return <Error message={error.message} />;
return <Display data={data} />;
}
useMutation を使ったミューテーション
update コールバックを使って、ミューテーション後にキャッシュを更新します。
import { useMutation, gql } from "@apollo/client";
const ADD_ITEM = gql`
mutation AddItem($input: ItemInput!) {
addItem(input: $input) {
id
name
}
}
`;
function AddItemForm() {
const [addItem, { loading }] = useMutation(ADD_ITEM, {
update(cache, { data: { addItem } }) {
cache.modify({
fields: {
items(existingItems = []) {
const newItemRef = cache.writeFragment({
data: addItem,
fragment: gql`
fragment NewItem on Item {
id
name
}
`,
});
return [...existingItems, newItemRef];
},
},
});
},
// Or use refetchQueries for simpler cases
// refetchQueries: [{ query: GET_ITEMS }],
});
return (
<form onSubmit={(e) => {
e.preventDefault();
addItem({ variables: { input: { name: "New Item" } } });
}}>
<button type="submit" disabled={loading}>Add</button>
</form>
);
}
エラー処理
GraphQL はエラーがあっても部分的なデータを返すことがあります(単一のエラーでリクエスト全体が失敗する REST とは異なります)。errorPolicy を使って、この動作を制御します。
| ポリシー | 動作 |
|---|---|
none | GraphQL エラーをすべてネットワークエラーとして扱い、データを破棄します(デフォルト) |
ignore | GraphQL エラーを無視し、データのみを返します |
all | データとエラーの両方を返し、部分的なデータのレンダリングを可能にします |
import { useQuery } from "@apollo/client";
function Component() {
const { data, error } = useQuery(QUERY, {
errorPolicy: "all" // Receive partial data with errors
});
if (error) {
// Check if we have partial data to display
if (data) {
// Render partial data with error notification
return (
<div>
<ErrorBanner message={error.message} />
<Display data={data} />
</div>
);
}
// No data at all - show full error
if (error.networkError) {
return <div>Network error: {error.message}</div>;
}
return <div>Error: {error.graphQLErrors[0]?.message}</div>;
}
return <div>{data?.field}</div>;
}
キャッシュの型ポリシー
キャッシュの正規化、カスタム識別子、フィールドのマージを設定します。
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Offset-based pagination with proper merging
items: {
keyArgs: ["filter"], // Cache separately per filter value
merge(existing = [], incoming, { args }) {
const offset = args?.offset ?? 0;
const merged = existing.slice(0);
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
// Custom cache key using different field than 'id'
User: {
keyFields: ["email"], // Use email as unique identifier
},
// Entities without id field
Token: {
keyFields: false, // Treat as singleton (no normalization)
},
// Composite key for join tables
OrderItem: {
keyFields: ["orderId", "productId"],
},
},
});
主な概念:
- キャッシュの正規化:Apollo は
__typename:idをキャッシュキーとして使用し、オブジェクトをフラットなルックアップテーブルに保存します - keyFields:エンティティを識別するフィールドをカスタマイズします(デフォルトは
idまたは_id) - keyArgs:個別のキャッシュエントリーを作成する引数を制御します
- merge:既存のデータと受信したデータを組み合わせる方法を定義します(ページネーションに不可欠です)
フェッチポリシー
| ポリシー | 動作 |
|---|---|
cache-first | キャッシュを読み取り、データがなければ取得します(デフォルト) |
cache-only | キャッシュのみを読み取り、データを取得しません |
network-only | 常にデータを取得し、キャッシュを更新します |
no-cache | 常にデータを取得し、キャッシュしません |
cache-and-network | すぐにキャッシュを返し、その後データを取得します |
Apollo Server
サーバーのセットアップ
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
interface Context {
user?: User;
db: Database;
}
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await getUserFromToken(req.headers.authorization),
db: await getDatabase(),
}),
listen: { port: 4000 },
});
スキーマ設計の原則
- デフォルトで非 null にする - フィールドが正当に null になり得る場合を除き、
!を付けます - 具体的な型を優先する - 識別子には
ID!を使用し、日付にはカスタムスカラーを使用します - クライアント向けに設計する - データベーススキーマではなく、UI のニーズに合わせてスキーマを構成します
- ミューテーションには入力型を使用する - 関連する引数をまとめます
type Query {
user(id: ID!): User
users(filter: UserFilter, pagination: Pagination): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
input CreateUserInput {
email: String!
name: String!
}
type CreateUserPayload {
user: User
errors: [Error!]
}
リゾルバー
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.db.users.findById(id);
},
},
Mutation: {
createUser: async (_, { input }, context) => {
if (!context.user) {
throw new GraphQLError("Not authenticated", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const user = await context.db.users.create(input);
return { user, errors: [] };
},
},
// Field resolvers for computed/related data
User: {
posts: (parent, _, context) => {
return context.db.posts.findByUserId(parent.id);
},
},
};
エラー処理
説明的なコードを付けた GraphQLError をスローします。
import { GraphQLError } from "graphql";
// In resolver
if (!user) {
throw new GraphQLError("User not found", {
extensions: {
code: "NOT_FOUND",
argumentName: "id",
},
});
}
// In context for auth errors
context: async ({ req }) => {
const user = await getUser(req);
if (!user) {
throw new GraphQLError("Authentication required", {
extensions: {
code: "UNAUTHENTICATED",
http: { status: 401 },
},
});
}
return { user };
};
標準エラーコード
| コード | 使用場面 |
|---|---|
UNAUTHENTICATED | 認証情報がない、または無効 |
FORBIDDEN | 認証済みだが、認可されていない |
BAD_USER_INPUT | 引数の値が無効 |
NOT_FOUND | 要求されたリソースが存在しない |
INTERNAL_SERVER_ERROR | 予期しないサーバーエラー |
パフォーマンスのヒント
- DataLoader を使用する - データベース呼び出しをバッチ処理してキャッシュし、N+1 クエリを回避します
- ページネーションを実装する - 上限のないリストは返さないでください
- 永続化クエリを使用する - 本番環境でリクエストサイズを削減します
- APM を有効にする - クエリのパフォーマンス監視には Apollo Studio を使用します
- フラグメントを遅延読み込みする -
@deferディレクティブを使って、大きなクエリを分割します - キャッシュ TTL を設定する - キャッシュされたレスポンスに適切な
maxAgeを設定します - クエリの深さを制限する - パフォーマンスの問題につながる深くネストしたクエリを防ぎます
- クエリの複雑度の上限を設定する - フィールドにコストを割り当て、複雑すぎるクエリを拒否します
セキュリティのベストプラクティス
- クエリの深さ制限 - 悪意のある深くネストされたクエリを防止します
- クエリの複雑度分析 - フィールドにコストを割り当て、しきい値を超えるクエリを拒否します
- レート制限 - クライアント/IP ごとにリクエストを制限します
- 本番環境でのイントロスペクションの無効化 - 権限のないユーザーからスキーマを隠します
- 入力検証 - リゾルバー内ですべてのユーザー入力を検証します
- フィールド単位の認可 - クエリレベルだけでなく、フィールドリゾルバー内でも権限を確認します
// Example: formatError to hide internal errors in production
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== "production",
formatError: (error) => {
// Log full error internally
console.error(error);
// Don't expose internal errors to clients
if (error.extensions?.code === "INTERNAL_SERVER_ERROR") {
return new GraphQLError("Internal server error", {
extensions: { code: "INTERNAL_SERVER_ERROR" },
});
}
return error;
},
});
Relay スタイルのパターン
グローバル ID
グローバルに一意な識別子には、base64 でエンコードした type:uuid 形式を使用します。これは Relay Global Object Identification Specification に準拠しています。
利点:
- クライアント側のキャッシュとデータの再取得を効率化できます
node(id: ID!)クエリを使って、ID で任意のエンティティを取得できます- ID に埋め込まれた型情報により、誤った型のクエリを防げます
- Relay などのライブラリは、ページネーションや再取得のクエリを自動生成できます
// utils/globalId.ts
export type GlobalIdType = "user" | "request" | "classified" | "purchase";
export function encodeGlobalId(type: GlobalIdType, uuid: string): string {
return Buffer.from(`${type}:${uuid}`).toString("base64");
}
export function decodeGlobalId(globalId: string): { type: string; uuid: string } {
const decoded = Buffer.from(globalId, "base64").toString("utf-8");
const [type, uuid] = decoded.split(":");
if (!type || !uuid) {
throw new Error("Invalid global ID format");
}
return { type, uuid };
}
// Examples
encodeGlobalId("request", "abc-123"); // -> "cmVxdWVzdDphYmMtMTIz"
decodeGlobalId("cmVxdWVzdDphYmMtMTIz"); // -> { type: 'request', uuid: 'abc-123' }
Node インターフェース
エンティティを統一的に取得するため、Node インターフェースを実装します。これは Relay specification の中核となるパターンで、次の機能を提供します。
- ID によって任意のオブジェクトを再取得する標準的な方法
- クライアントライブラリでの効率的なキャッシュ管理
- 型安全なポリモーフィッククエリ
# schema/node.graphql
interface Node {
id: ID!
}
extend type Query {
"""
Fetch any entity by its global ID.
Returns null if the ID is invalid or the entity doesn't exist.
"""
node(id: ID!): Node
}
// resolvers/node.ts
export const nodeResolvers = {
Query: {
async node(parent: any, { id }: { id: string }, context: GraphQLContext) {
try {
const { type, uuid } = decodeGlobalId(id);
switch (type) {
case "request":
return context.loaders.request.load(uuid);
case "classified":
return context.loaders.classified.load(uuid);
case "purchase":
return context.loaders.purchase.load(uuid);
default:
return null;
}
} catch (error) {
return null; // Silent failure for invalid IDs
}
},
},
Node: {
__resolveType(obj: any) {
// Detect type based on unique fields or __typename
if (obj.__typename) return obj.__typename;
if ("condition" in obj && "priceMinimum" in obj) return "Request";
if ("platformConfigId" in obj && "visibilityStatus" in obj) return "Classified";
if ("buyerEmail" in obj || "shippingAddress" in obj) return "Purchase";
return null;
},
},
};
グローバル ID を持つエンティティ型
type Request implements Node {
id: ID! # Global ID (base64 encoded)
title: String!
description: String!
# ... other fields
}
// resolvers/request.ts
Request: {
// Encode database UUID to global ID
id(request: any) {
return encodeGlobalId("request", request.id);
},
}
バッチ処理用 DataLoader
効率的な Node クエリの実行と N+1 問題の回避に不可欠です。N+1 問題とは、項目のリストを取得する際に 1 回クエリを実行し、その後、各項目の関連データを個別に取得するために N 回クエリを実行する問題です。
DataLoader の主な原則:
- リクエストごとに新しい DataLoader を作成します(リクエスト間で共有しないでください)
- 結果は必ず、入力キーと同じ順序で返します
- 1 回のリクエスト内でキャッシュを使い、重複する取得を排除します
- 過度に大きなクエリを防ぐため、
maxBatchSizeを設定します
// dataloaders/request.loader.ts
import DataLoader from "dataloader";
export function createRequestLoader(db: Database) {
return new DataLoader<string, any>(
async (uuids) => {
// Batch fetch all requested entities in a single query
// e.g., SELECT * FROM requests WHERE id IN (uuid1, uuid2, ...)
const results = await db.requests.findByIds([...uuids]);
// CRITICAL: Return results in same order as input uuids
// DataLoader requires 1:1 mapping between keys and results
const resultMap = new Map(results.map((r) => [r.id, r]));
return uuids.map((uuid) => resultMap.get(uuid) ?? null);
},
{
cache: true, // Cache within this request
maxBatchSize: 100, // Limit batch size for DB query performance
}
);
}
// dataloaders/index.ts
export interface Loaders {
request: DataLoader<string, any>;
classified: DataLoader<string, any>;
purchase: DataLoader<string, any>;
}
export function createLoaders(db: Database): Loaders {
return {
request: createRequestLoader(db),
classified: createClassifiedLoader(db),
purchase: createPurchaseLoader(db),
};
}
DataLoader を含むコンテキスト
リクエストごとに新しい DataLoader を作成します。
// server.ts
export interface GraphQLContext {
user: User | null;
permissions: Permissions;
db: Database;
loaders: Loaders;
}
app.use(
"/graphql",
expressMiddleware(apolloServer, {
context: async ({ req }): Promise<GraphQLContext> => ({
user: (req as any).connectedUser ?? null,
permissions: (req as any).connectedUserPermissions,
db: database,
loaders: createLoaders(database), // Fresh loaders per request
}),
})
);
オフセットベースのページネーション(コネクションパターン)
# schema/common.graphql
type PageInfo {
total: Int!
offset: Int!
limit: Int!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
input PaginationInput {
offset: Int = 0
limit: Int = 50
}
type RequestConnection {
items: [Request!]!
pageInfo: PageInfo!
}
extend type Query {
requests(pagination: PaginationInput): RequestConnection!
}
// resolvers/request.ts
async requests(
parent: any,
{ pagination = {} }: { pagination?: { offset?: number; limit?: number } },
context: GraphQLContext
) {
const { offset = 0, limit = 50 } = pagination;
const [items, total] = await Promise.all([
context.db.requests.findMany({
where: { deletedAt: null },
skip: offset,
take: limit,
orderBy: { createdAt: "asc" },
}),
context.db.requests.count({ where: { deletedAt: null } }),
]);
return {
items,
pageInfo: {
total,
offset,
limit,
hasNextPage: offset + limit < total,
hasPreviousPage: offset > 0,
},
};
}
権限エラーのサイレント処理(クエリ)
クエリでは、エラーをスローする代わりに null を返します。
async request(parent: any, { id }: { id: string }, context: GraphQLContext) {
try {
const { type, uuid } = decodeGlobalId(id);
// Wrong type - return null silently
if (type !== "request") {
return null;
}
// No permission - return null silently
if (!context.permissions?.canViewRequest?.(uuid)) {
return null;
}
const request = await context.loaders.request.load(uuid);
return request ?? null;
} catch (error) {
console.error("Error fetching request:", error);
return null;
}
}
クエリの例
# Fetch single entity by global ID
query GetRequest {
request(id: "cmVxdWVzdDphYmMxMjM=") {
id
title
description
}
}
# Using node query for any entity
query GetNode {
node(id: "cmVxdWVzdDphYmMxMjM=") {
id
... on Request {
title
price
}
... on Classified {
status
url
}
}
}
# Paginated list
query ListRequests {
requests(pagination: { offset: 0, limit: 10 }) {
items {
id
title
}
pageInfo {
total
hasNextPage
}
}
}