mcpField
A
apollo-graphql-best-practices

apollo-graphql-best-practices

github.com/maximepzv開発042 回インストール40 週間前に更新
リポジトリ

apollo-graphql-best-practices について

apollo-graphql-best-practicesは、Apollo ClientやApollo Serverを使うアプリを開発・保守するチーム向けのリファレンススキルです。スキーマやリゾルバー、キャッシュ、エラー処理、性能・セキュリティ、Relay形式のパターンを扱います。

主な機能

  1. 01Apollo Clientの設定とクエリ
  2. 02キャッシュポリシーとページネーション
  3. 03スキーマとリゾルバーの設計指針
  4. 04GraphQLのエラー処理
  5. 05性能とセキュリティの実践
  6. 06Relay IDとDataLoaderのパターン

活用シーン

  1. 01Apolloプロジェクトの開始
  2. 02GraphQLスキーマとリゾルバーのレビュー
  3. 03キャッシュとページネーションの調整
  4. 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 を使って、この動作を制御します。

ポリシー動作
noneGraphQL エラーをすべてネットワークエラーとして扱い、データを破棄します(デフォルト)
ignoreGraphQL エラーを無視し、データのみを返します
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 },
});
スキーマ設計の原則
  1. デフォルトで非 null にする - フィールドが正当に null になり得る場合を除き、! を付けます
  2. 具体的な型を優先する - 識別子には ID! を使用し、日付にはカスタムスカラーを使用します
  3. クライアント向けに設計する - データベーススキーマではなく、UI のニーズに合わせてスキーマを構成します
  4. ミューテーションには入力型を使用する - 関連する引数をまとめます
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予期しないサーバーエラー

パフォーマンスのヒント

  1. DataLoader を使用する - データベース呼び出しをバッチ処理してキャッシュし、N+1 クエリを回避します
  2. ページネーションを実装する - 上限のないリストは返さないでください
  3. 永続化クエリを使用する - 本番環境でリクエストサイズを削減します
  4. APM を有効にする - クエリのパフォーマンス監視には Apollo Studio を使用します
  5. フラグメントを遅延読み込みする - @defer ディレクティブを使って、大きなクエリを分割します
  6. キャッシュ TTL を設定する - キャッシュされたレスポンスに適切な maxAge を設定します
  7. クエリの深さを制限する - パフォーマンスの問題につながる深くネストしたクエリを防ぎます
  8. クエリの複雑度の上限を設定する - フィールドにコストを割り当て、複雑すぎるクエリを拒否します

セキュリティのベストプラクティス

  1. クエリの深さ制限 - 悪意のある深くネストされたクエリを防止します
  2. クエリの複雑度分析 - フィールドにコストを割り当て、しきい値を超えるクエリを拒否します
  3. レート制限 - クライアント/IP ごとにリクエストを制限します
  4. 本番環境でのイントロスペクションの無効化 - 権限のないユーザーからスキーマを隠します
  5. 入力検証 - リゾルバー内ですべてのユーザー入力を検証します
  6. フィールド単位の認可 - クエリレベルだけでなく、フィールドリゾルバー内でも権限を確認します
// 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
    }
  }
}

コメント