Use Cases (更新: 2026/6/2)

Claude CodeでGraphQL開発を安全に進める実践ガイド|SDL・型付きResolver・N+1対策

Claude CodeでGraphQL APIを設計、型付きResolver、N+1、認可、複雑度制限、テストまで実装する実践ガイド。

Claude CodeでGraphQL開発を安全に進める実践ガイド|SDL・型付きResolver・N+1対策

GraphQL開発をClaude Codeに任せるとき、最初に決めるべきことは「どの型を作るか」ではありません。スキーマを契約として扱うのか、既存のリゾルバー実装から型を育てるのか、認可をどの層に置くのか、N+1をどこで防ぐのか、深すぎるクエリをどう止めるのかを先に決めます。ここが曖昧なまま「GraphQL APIを作って」と頼むと、デモは動いても、公開後に重いクエリ、認可漏れ、型のずれ、読みにくいエラーが残ります。

この記事では、Claude Codeを単なるコード生成係ではなく、GraphQL実装のレビュー相手として使う手順をまとめます。SDLは「GraphQLのスキーマ定義を言語に依存しない形で書く記法」、resolverは「フィールドの値を実際に取りに行く関数」、DataLoaderは「1リクエスト内の同種の読み取りをまとめる仕組み」と読み替えてください。例はTypeScript、GraphQL Code Generator、DataLoader、GraphQL.js、Vitestで構成します。

公式情報は、GraphQLのSchema and TypesPaginationAuthorizationSecurityGraphQL SpecificationのResponse FormatDataLoader READMEGraphQL Code Generator TypeScript ResolversClaude Code公式ドキュメントを基準にします。関連して、API全体の考え方はClaude Code API開発ガイド、テストの深掘りはAPIテスト自動化ガイド、プロンプト設計はプロンプトテクニック完全ガイドも合わせて確認してください。

schema-firstとresolver-firstを使い分ける

GraphQLの設計には大きく2つの進め方があります。schema-firstは、先にSDLで公開契約を固定し、その契約を満たすresolverとテストを書きます。resolver-firstは、既存のドメインサービスやTypeScriptコードからresolverを組み、必要に応じてスキーマを整理します。Claude Codeに任せるなら、新規API、外部クライアント、複数チームが触るAPIではschema-firstがレビューしやすいです。逆に、既存REST APIやDBアクセス層の移行ではresolver-firstで棚卸しを始め、最後にSDLを整える方が現実的です。

実務のユースケースは少なくとも3つあります。1つ目はSaaS管理画面です。チーム、請求、監査ログのように権限境界が多いので、スキーマで「何が公開されるか」を先に固定します。2つ目はモバイルアプリ向けAPIです。通信回数を減らせる一方、クライアントが自由に深い選択を投げられるため、ページネーションと複雑度制限を最初から入れます。3つ目は社内分析ツールです。クエリの自由度は高くしたいものの、個人情報やテナント境界を越えないよう、resolverから呼ぶ業務ロジックに認可を寄せます。

Claude Codeへの最初の依頼は、次のように設計判断を含めます。

GraphQL APIをschema-firstで設計してください。
前提:
- SDLをsrc/graphql/schema.graphqlに置く
- TypeScript resolverはGraphQL Code Generatorで型付けする
- リストはcursor-based connectionにする
- 認可はresolver内に直書きせず、repo/service層で一元化する
- DataLoaderはリクエストごとに作り、グローバル共有しない
- 深さと複雑度のvalidation rule、エラー整形、Vitestを含める
- 実装後に実行すべきコマンドと落とし穴も出す

この段階で「Apolloで作る」「Yogaで作る」より前に、契約、認可、負荷制御、テストを固定します。ライブラリ選定はプロジェクトに合わせて変わりますが、この4点はほぼ毎回必要です。

SDLを契約として置く

次のSDLは商品レビューAPIの最小例です。GraphQL公式のPaginationページが説明するconnection modelに合わせ、edgesnodecursorpageInfoを持たせています。Product.reviewsのようなネストは便利ですが、N+1と深いクエリの入口にもなるため、後続のDataLoaderとdemand controlをセットで扱います。

npm install graphql @graphql-tools/schema dataloader
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers vitest tsx typescript
# src/graphql/schema.graphql
interface Node {
  id: ID!
}

type Product implements Node {
  id: ID!
  title: String!
  isPublic: Boolean!
  averageRating: Float
  reviews(first: Int = 10, after: String): ReviewConnection!
  createdAt: String!
}

type Review implements Node {
  id: ID!
  product: Product!
  rating: Int!
  body: String!
  authorId: ID!
  createdAt: String!
}

type ProductEdge {
  node: Product!
  cursor: String!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}

type ReviewEdge {
  node: Review!
  cursor: String!
}

type ReviewConnection {
  edges: [ReviewEdge!]!
  pageInfo: PageInfo!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

input CreateReviewInput {
  productId: ID!
  rating: Int!
  body: String!
}

type Query {
  product(id: ID!): Product
  products(first: Int = 10, after: String): ProductConnection!
}

type Mutation {
  createReview(input: CreateReviewInput!): Review!
}

ここで重要なのは、GraphQLの型が入力検証のすべてを代替しないことです。rating: Int!は整数であることを示しますが、1から5の範囲であること、本文の最大長、HTMLの扱い、公開範囲はresolverや業務ロジックで確認します。Claude Codeには「SDLで表現できる制約」と「resolver/serviceで守る制約」を分けて書かせると、レビューがしやすくなります。

型付きresolverを生成する

TypeScriptでGraphQLを書くときの失敗例は、resolverのargscontextanyで受けてしまうことです。最初は速く見えますが、フィールド名変更、nullable変更、認可情報の追加で破綻します。GraphQL Code Generatorのtypescript-resolversを使うと、SDLからresolverの型を生成できます。

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

const config: CodegenConfig = {
  schema: "src/graphql/schema.graphql",
  generates: {
    "src/graphql/generated/resolvers-types.ts": {
      plugins: ["typescript", "typescript-resolvers"],
      config: {
        contextType: "../context#GraphQLContext",
        mappers: {
          Product: "../models#ProductRow",
          Review: "../models#ReviewRow",
        },
        useIndexSignature: true,
      },
    },
  },
};

export default config;
{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "test:graphql": "npm run codegen && vitest run test/graphql.test.ts"
  }
}

mappersは「GraphQL型」と「DBやサービスから返る行の型」を対応させる設定です。GraphQLのProductreviewsaverageRatingを持ちますが、DBのProductRowには存在しないことがあります。この差を型で明示すると、Claude Codeが不要なフィールドをDB行に生やすような修正をしにくくなります。

DataLoaderでN+1を潰す

N+1問題とは、リストを1回取得した後、各行の関連データを1件ずつ取りに行ってしまう問題です。商品10件を取ったあと、各商品のレビューを10回問い合わせるような形です。DataLoaderは同じリクエスト内のloadをまとめ、キーの順番に対応する結果配列を返すことで、この問題を抑えます。ただし、READMEが説明する通り、DataLoaderのキャッシュはRedisの代わりではありません。ユーザーをまたいで共有せず、リクエストごとに作ります。

// src/graphql/models.ts
export type Viewer = { id: string; role: "user" | "admin" } | null;

export type ProductRow = {
  id: string;
  title: string;
  ownerId: string;
  isPublic: boolean;
  createdAt: string;
};

export type ReviewRow = {
  id: string;
  productId: string;
  rating: number;
  body: string;
  authorId: string;
  createdAt: string;
};

const products: ProductRow[] = [
  { id: "prod_1", title: "Claude Code Playbook", ownerId: "user_1", isPublic: true, createdAt: "2026-06-02T00:00:00.000Z" },
  { id: "prod_private", title: "Internal Prompt Pack", ownerId: "user_2", isPublic: false, createdAt: "2026-06-02T00:00:00.000Z" },
];

const reviews: ReviewRow[] = [
  { id: "rev_1", productId: "prod_1", rating: 5, body: "Practical.", authorId: "user_1", createdAt: "2026-06-02T00:10:00.000Z" },
  { id: "rev_2", productId: "prod_1", rating: 4, body: "Useful for reviews.", authorId: "user_2", createdAt: "2026-06-02T00:20:00.000Z" },
];

function canSeeProduct(viewer: Viewer, product: ProductRow) {
  return product.isPublic || viewer?.role === "admin" || viewer?.id === product.ownerId;
}

export const repo = {
  productById(id: string, viewer: Viewer) {
    const product = products.find((item) => item.id === id);
    return product && canSeeProduct(viewer, product) ? product : null;
  },
  products(viewer: Viewer) {
    return products.filter((product) => canSeeProduct(viewer, product));
  },
  reviewsByProductIds(productIds: readonly string[], viewer: Viewer) {
    const visibleIds = new Set(
      products.filter((product) => productIds.includes(product.id) && canSeeProduct(viewer, product)).map((product) => product.id),
    );
    return reviews.filter((review) => visibleIds.has(review.productId));
  },
  createReview(input: { productId: string; rating: number; body: string }, viewer: NonNullable<Viewer>) {
    const product = products.find((item) => item.id === input.productId);
    if (!product || !canSeeProduct(viewer, product)) return null;
    if (input.rating < 1 || input.rating > 5) return null;

    const review: ReviewRow = {
      id: `rev_${reviews.length + 1}`,
      productId: input.productId,
      rating: input.rating,
      body: input.body.slice(0, 1000),
      authorId: viewer.id,
      createdAt: new Date().toISOString(),
    };
    reviews.push(review);
    return review;
  },
};
// src/graphql/context.ts
import { randomUUID } from "node:crypto";
import DataLoader from "dataloader";
import { repo, type ReviewRow, type Viewer } from "./models";

function createLoaders(viewer: Viewer) {
  return {
    reviewsByProductId: new DataLoader<string, ReviewRow[]>(async (productIds) => {
      const rows = repo.reviewsByProductIds(productIds, viewer);
      const grouped = new Map<string, ReviewRow[]>();

      for (const productId of productIds) grouped.set(productId, []);
      for (const review of rows) grouped.get(review.productId)?.push(review);

      return productIds.map((productId) => grouped.get(productId) ?? []);
    }),
  };
}

export type GraphQLContext = {
  viewer: Viewer;
  requestId: string;
  loaders: ReturnType<typeof createLoaders>;
};

export function createContext(options: { viewer?: Viewer; requestId?: string } = {}): GraphQLContext {
  const viewer = options.viewer ?? null;
  return {
    viewer,
    requestId: options.requestId ?? randomUUID(),
    loaders: createLoaders(viewer),
  };
}
// src/graphql/resolvers.ts
import { GraphQLError } from "graphql";
import type { Resolvers } from "./generated/resolvers-types";
import { repo, type ReviewRow } from "./models";

function encodeCursor(id: string) {
  return Buffer.from(id).toString("base64url");
}

function decodeCursor(cursor?: string | null) {
  if (!cursor) return null;
  try {
    return Buffer.from(cursor, "base64url").toString("utf8");
  } catch {
    return null;
  }
}

function connectionFromRows<T extends { id: string }>(rows: T[], args: { first?: number | null; after?: string | null }) {
  const first = Math.min(Math.max(args.first ?? 10, 1), 50);
  const afterId = decodeCursor(args.after);
  const startIndex = afterId ? rows.findIndex((row) => row.id === afterId) + 1 : 0;
  const slice = rows.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + first + 1);
  const page = slice.slice(0, first);

  return {
    edges: page.map((node) => ({ node, cursor: encodeCursor(node.id) })),
    pageInfo: {
      hasNextPage: slice.length > first,
      endCursor: page.length > 0 ? encodeCursor(page[page.length - 1].id) : null,
    },
  };
}

export const resolvers: Resolvers = {
  Node: {
    __resolveType(parent) {
      return "rating" in parent ? "Review" : "Product";
    },
  },
  Query: {
    product: (_parent, args, context) => repo.productById(args.id, context.viewer),
    products: (_parent, args, context) => connectionFromRows(repo.products(context.viewer), args),
  },
  Product: {
    reviews: async (product, args, context) => {
      const rows = await context.loaders.reviewsByProductId.load(product.id);
      return connectionFromRows(rows, args);
    },
    averageRating: async (product, _args, context) => {
      const rows = await context.loaders.reviewsByProductId.load(product.id);
      if (rows.length === 0) return null;
      return rows.reduce((sum: number, review: ReviewRow) => sum + review.rating, 0) / rows.length;
    },
  },
  Review: {
    product: (review, _args, context) => {
      const product = repo.productById(review.productId, context.viewer);
      if (!product) {
        throw new GraphQLError("Product is not available.", { extensions: { code: "FORBIDDEN" } });
      }
      return product;
    },
  },
  Mutation: {
    createReview: (_parent, args, context) => {
      if (!context.viewer) {
        throw new GraphQLError("Login required.", { extensions: { code: "UNAUTHENTICATED" } });
      }

      const review = repo.createReview(args.input, context.viewer);
      if (!review) {
        throw new GraphQLError("Review could not be created.", { extensions: { code: "BAD_USER_INPUT" } });
      }
      return review;
    },
  },
};

この例では、認可境界をrepo.productByIdrepo.productsに寄せています。GraphQL公式のAuthorizationページが勧めるように、認可はできるだけ業務ロジック側の単一の真実に置きます。resolverごとにif (context.viewer...)を散らすと、別の入口で条件がずれてしまいます。

深さと複雑度をvalidationで制限する

GraphQLの強みは、クライアントが必要なフィールドを選べることです。しかし、同じ自由度は攻撃面にもなります。深いネスト、巨大なfirst、多数のalias、batch実行が重なると、RESTより読みづらい負荷が生まれます。GraphQL公式のSecurityページは、trusted documents、ページネーション、depth limiting、breadth/batch limiting、rate limiting、query complexity analysisを組み合わせる考え方を示しています。

次はGraphQL.jsのvalidation ruleとして使える簡易ガードです。literalのfirstlastを複雑度に反映します。変数で渡る値まではvalidation時に確定しないため、resolver側でも必ずfirstを上限で丸めます。

// src/graphql/demandControl.ts
import {
  GraphQLError,
  Kind,
  type ASTVisitor,
  type FieldNode,
  type FragmentDefinitionNode,
  type SelectionSetNode,
  type ValidationContext,
} from "graphql";

type Limits = {
  maxDepth: number;
  maxComplexity: number;
  defaultListSize: number;
  maxListSize: number;
};

const defaultLimits: Limits = {
  maxDepth: 6,
  maxComplexity: 300,
  defaultListSize: 10,
  maxListSize: 50,
};

export function depthAndComplexityRule(limits: Partial<Limits> = {}) {
  const merged = { ...defaultLimits, ...limits };

  return (context: ValidationContext): ASTVisitor => {
    const fragments = new Map<string, FragmentDefinitionNode>();

    return {
      FragmentDefinition(node) {
        fragments.set(node.name.value, node);
      },
      OperationDefinition(node) {
        const score = scoreSelectionSet(node.selectionSet, fragments, new Set(), 0, 1, merged);

        if (score.depth > merged.maxDepth) {
          context.reportError(new GraphQLError(`GraphQL operation is too deep: ${score.depth}`, { nodes: node }));
        }
        if (score.complexity > merged.maxComplexity) {
          context.reportError(
            new GraphQLError(`GraphQL operation is too complex: ${score.complexity}`, { nodes: node }),
          );
        }
      },
    };
  };
}

function listMultiplier(field: FieldNode, limits: Limits) {
  const arg = field.arguments?.find((item) => item.name.value === "first" || item.name.value === "last");
  if (!arg) return 1;
  if (arg.value.kind !== Kind.INT) return limits.defaultListSize;
  return Math.min(Number.parseInt(arg.value.value, 10), limits.maxListSize);
}

function scoreSelectionSet(
  selectionSet: SelectionSetNode,
  fragments: Map<string, FragmentDefinitionNode>,
  seenFragments: Set<string>,
  depth: number,
  multiplier: number,
  limits: Limits,
) {
  let maxDepth = depth;
  let complexity = 0;

  for (const selection of selectionSet.selections) {
    if (selection.kind === Kind.FIELD) {
      const nextMultiplier = multiplier * listMultiplier(selection, limits);
      complexity += nextMultiplier;

      if (selection.selectionSet) {
        const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth + 1, nextMultiplier, limits);
        maxDepth = Math.max(maxDepth, child.depth);
        complexity += child.complexity;
      }
    }

    if (selection.kind === Kind.INLINE_FRAGMENT) {
      const child = scoreSelectionSet(selection.selectionSet, fragments, seenFragments, depth, multiplier, limits);
      maxDepth = Math.max(maxDepth, child.depth);
      complexity += child.complexity;
    }

    if (selection.kind === Kind.FRAGMENT_SPREAD) {
      const fragmentName = selection.name.value;
      if (seenFragments.has(fragmentName)) continue;
      const fragment = fragments.get(fragmentName);
      if (!fragment) continue;

      seenFragments.add(fragmentName);
      const child = scoreSelectionSet(fragment.selectionSet, fragments, seenFragments, depth, multiplier, limits);
      maxDepth = Math.max(maxDepth, child.depth);
      complexity += child.complexity;
    }
  }

  return { depth: maxDepth, complexity };
}

本番では、このruleだけで安心しません。公開クライアントだけならtrusted documents、つまり事前に承認したGraphQL documentだけを許可する方式も検討します。さらにHTTPレート制限、タイムアウト、firstの上限、巨大文字列入力の検証を重ねます。Claude Codeには「深さ制限を入れて」ではなく、「どの攻撃をどの層で止めるか」を表にさせると抜けが減ります。

エラー形状と実行テストを固定する

GraphQLのレスポンスは、成功時のdata、失敗時のerrors、追加情報のextensionsを中心に扱います。GraphQL仕様では、実行前エラーではdataを返さないこと、フィールド実行中のエラーでは部分的なdataerrorsが共存し得ること、フィールドに紐づくエラーにはpathを含めることが整理されています。公開APIでは、DBエラーやスタックトレースをそのまま返さず、extensions.coderequestIdを揃えます。

// src/graphql/schema.ts
import { readFileSync } from "node:fs";
import { makeExecutableSchema } from "@graphql-tools/schema";
import { resolvers } from "./resolvers";

export const schema = makeExecutableSchema({
  typeDefs: readFileSync(new URL("./schema.graphql", import.meta.url), "utf8"),
  resolvers,
});
// src/graphql/execute.ts
import {
  GraphQLError,
  execute,
  parse,
  specifiedRules,
  validate,
  type ExecutionResult,
} from "graphql";
import { createContext, type GraphQLContext } from "./context";
import { depthAndComplexityRule } from "./demandControl";
import { schema } from "./schema";
import type { Viewer } from "./models";

type ExecuteOptions = {
  source: string;
  variables?: Record<string, unknown>;
  viewer?: Viewer;
  requestId?: string;
};

export async function executeGraphQL(options: ExecuteOptions): Promise<ExecutionResult> {
  const context = createContext({ viewer: options.viewer, requestId: options.requestId });
  const document = parse(options.source);
  const validationErrors = validate(schema, document, [
    ...specifiedRules,
    depthAndComplexityRule({ maxDepth: 6, maxComplexity: 300 }),
  ]);

  if (validationErrors.length > 0) {
    return { errors: validationErrors.map((error) => maskError(error, context)) };
  }

  const result = await execute({
    schema,
    document,
    variableValues: options.variables,
    contextValue: context,
  });

  return {
    data: result.data,
    errors: result.errors?.map((error) => maskError(error, context)),
  };
}

function maskError(error: GraphQLError, context: GraphQLContext) {
  const code = typeof error.extensions.code === "string" ? error.extensions.code : "INTERNAL_SERVER_ERROR";
  const message = process.env.NODE_ENV === "production" ? "GraphQL request failed." : error.message;

  return new GraphQLError(message, {
    nodes: error.nodes,
    path: error.path,
    originalError: error.originalError,
    extensions: {
      code,
      requestId: context.requestId,
    },
  });
}
// test/graphql.test.ts
import { describe, expect, it } from "vitest";
import { executeGraphQL } from "../src/graphql/execute";

const viewer = { id: "user_1", role: "user" as const };

describe("GraphQL API", () => {
  it("returns products with reviews through typed resolvers", async () => {
    const result = await executeGraphQL({
      viewer,
      source: `
        query Products {
          products(first: 10) {
            edges {
              node {
                id
                title
                averageRating
                reviews(first: 5) {
                  edges { node { id rating body } }
                }
              }
            }
          }
        }
      `,
    });

    expect(result.errors).toBeUndefined();
    expect(result.data?.products.edges[0].node.title).toBe("Claude Code Playbook");
  });

  it("does not expose a private product to another user", async () => {
    const result = await executeGraphQL({
      viewer,
      source: `query PrivateProduct { product(id: "prod_private") { id title } }`,
    });

    expect(result.errors).toBeUndefined();
    expect(result.data?.product).toBeNull();
  });

  it("requires login for mutations", async () => {
    const result = await executeGraphQL({
      source: `
        mutation CreateReview {
          createReview(input: { productId: "prod_1", rating: 5, body: "Great" }) { id }
        }
      `,
    });

    expect(result.errors?.[0].extensions?.code).toBe("UNAUTHENTICATED");
  });

  it("rejects overly deep operations before execution", async () => {
    const result = await executeGraphQL({
      viewer,
      source: `
        query TooDeep {
          products(first: 50) {
            edges { node { reviews(first: 50) { edges { node { product { reviews(first: 50) { edges { node { id } } } } } } } } }
          }
        }
      `,
    });

    expect(result.errors?.[0].message).toContain("too");
  });
});

テストは正常系だけでは足りません。認可、未ログイン、深すぎるクエリ、範囲外入力、非公開データ、nullableの扱いを必ず入れます。Claude Codeに「テストも書いて」と頼むのではなく、「公開してはいけないデータが返らないこと」「validationで落ちること」「エラーのextensions.codeが揃うこと」を明示してください。

Claude Codeに渡す安全なプロンプト

GraphQL実装で危険なプロンプトは、「いい感じにGraphQL化して」「N+1も考えて」「セキュアにして」のような曖昧な依頼です。Claude Codeは文脈からかなり補完できますが、補完に任せた部分ほどレビューしづらくなります。

このリポジトリのGraphQL実装を改善してください。
触ってよい範囲:
- src/graphql/**
- test/graphql.test.ts

守る設計:
- schema-first。schema.graphqlを公開契約として扱う
- GraphQL Code GeneratorのResolvers型を使い、anyを増やさない
- 認可はrepo/service層に置き、resolverに条件を散らさない
- DataLoaderはcreateContext内でリクエストごとに作る
- list系fieldはfirstを1..50に丸め、connection形式で返す
- depthとcomplexityのvalidation ruleを入れる
- エラーはmessage, path, extensions.code, extensions.requestIdに揃える
- Vitestで正常系、未ログイン、非公開データ、深すぎるクエリを確認する

最後に:
- 変更ファイル
- 実行したコマンド
- 残ったリスク
- 公式ドキュメントと照合すべき点
を日本語で報告してください。

このプロンプトは、Claude Codeに「何を作るか」だけでなく「どこまで作れば完了か」を渡します。チーム運用では、この内容をCLAUDE.mdベストプラクティスに沿ってプロジェクト規約へ移すと、次回以降の差分が安定します。

よくある落とし穴

落とし穴は具体的に潰します。1つ目は、DataLoaderをグローバルに置いてユーザー間でキャッシュを共有することです。これは非公開データ漏れにつながります。2つ目は、firstの上限をschemaのdefault値だけで済ませることです。クライアントは大きな値を送れるため、resolverで丸めます。3つ目は、resolverに認可条件を散らすことです。別のqueryやmutationから同じデータへ到達したときに条件がずれます。

4つ目は、深さ制限だけで複雑度制限を入れないことです。浅いqueryでも、広いaliasや大きなlistで重くなります。5つ目は、GraphQLエラーに内部例外を出すことです。開発中は便利でも、本番ではDB名、フィールド候補、スタックトレースが漏れます。6つ目は、CodegenをCIに入れないことです。schemaを変えたのにresolver型を更新しないままレビューに出すと、型安全の意味が薄れます。

収益化の観点でも、GraphQLの品質は重要です。教材販売、会員記事、Gumroad導線、法人向け問い合わせ、管理画面が同じAPIを使うなら、認可漏れや重いクエリは信用に直結します。まず無料チートシートでClaude Codeへの依頼と確認手順を固定し、テンプレートが必要なら教材・テンプレート一覧を使ってください。チームでGraphQL設計、認可、テスト、CIまで整えるならClaude Code研修・導入相談で実リポジトリ前提に設計できます。

実際に試した結果

Masaがこの構成を小さな商品レビューAPIで試したところ、最も効果があったのはDataLoaderの自動生成そのものではなく、Claude Codeへの依頼に「認可境界」「firstの上限」「depth/complexity」「エラー形状」「テスト名」を最初から入れたことでした。以前はProduct.reviewsを追加した後にN+1を見つけ、さらに非公開商品のレビューが別queryから見えるかを手で確認していました。今回の形では、schema、resolver、validation、testが同じ差分に入り、レビュー時の確認がかなり短くなりました。

まとめ

Claude CodeでGraphQL開発を進めるなら、schema-firstかresolver-firstかを最初に決め、SDLを契約として扱い、Codegenでresolverを型付けし、DataLoaderをリクエスト単位で作り、認可を業務ロジックへ寄せ、深さと複雑度を制限してください。さらに、GraphQL仕様に沿ったエラー形状とテストを同じ作業単位に含めます。

GraphQLは柔軟ですが、柔軟さを制御しないと本番で重く危険になります。Claude Codeには自由に作らせるのではなく、守る境界、禁止する実装、検証コマンドを渡してください。それだけで生成結果は「動くデモ」から「レビューできる実装」に近づきます。

#Claude Code #GraphQL #API開発 #TypeScript #DataLoader
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。