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

用 Claude Code 做可靠 GraphQL 开发:SDL、类型化 Resolver、DataLoader 与测试

用 Claude Code 实作 GraphQL API:SDL、类型化 Resolver、N+1、授权边界、复杂度限制、错误与测试。

用 Claude Code 做可靠 GraphQL 开发:SDL、类型化 Resolver、DataLoader 与测试

把 GraphQL API 交给 Claude Code 时,第一步不是列出 UserProductReview 这些类型。更重要的是先决定:公开契约放在哪里,授权在哪一层执行,N+1 查询如何合并,过深或过贵的操作如何拒绝,错误格式和测试如何证明它可以上线。只说「帮我做 GraphQL」通常能得到能跑的 demo,但也容易留下授权泄漏、慢查询、类型漂移和难排查的错误。

本文把 Claude Code 当作 GraphQL 实作伙伴,而不是一次性代码生成器。SDL 可以理解为「用文本描述 GraphQL API 契约的语言」;resolver 是「为字段取值的函数」;DataLoader 是「在单个请求内把同类读取合并的工具」。示例使用 TypeScript、GraphQL Code Generator、DataLoader、GraphQL.js 和 Vitest。

官方资料以这些为准:Schemas and TypesPaginationAuthorizationSecurity、GraphQL 规范的 Response FormatDataLoader READMEGraphQL Code Generator TypeScript ResolversClaude Code docs。站内可继续读 API 开发API 测试提示词技巧

先选择 schema-first 还是 resolver-first

GraphQL 项目常见两种起点。schema-first 是先写 SDL,把它当作公开契约,再实现 resolver 和测试。resolver-first 是从已有业务服务、TypeScript 函数或数据库访问层出发,再整理出 schema。新 API、移动端 API、跨团队 API 更适合 schema-first,因为评审能先看清「对外暴露什么」。迁移旧 REST 或内部服务时,resolver-first 往往更现实,因为真实行为已经在代码里。

三个场景很典型。SaaS 后台有团队、账单、审计日志等权限边界,适合先固定 schema。移动端 API 想减少请求次数,但必须提前加入分页、深度限制和稳定错误格式。内部分析工具可能更重视复用既有数据服务,但授权仍要放在共享业务逻辑中,而不是散落在每个 resolver。

给 Claude Code 的初始提示词应该包含设计选择。

Design this GraphQL API schema-first.
Requirements:
- Put SDL in src/graphql/schema.graphql
- Type TypeScript resolvers with GraphQL Code Generator
- Use cursor-based connections for list fields
- Keep authorization in repo/service functions, not scattered through resolvers
- Create DataLoader instances per request inside createContext
- Include depth and complexity validation, safe error formatting, and Vitest tests
- Report verification commands and remaining pitfalls after implementation

这比「用 Apollo 做一个 API」更重要。具体服务器库可以因项目而异,但契约、授权、负载控制、测试这四件事不能让 Claude Code 自由猜。

把 SDL 当作契约

下面是商品评论 API 的最小 SDL。它采用 GraphQL 官方 Pagination 文档介绍的 connection 结构:edgesnodecursorpageInfoProduct.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 清洗、私有商品可见性已经处理。把这些约束写进 repo 或 service 层,再让 resolver 调用,才更容易评审。

生成类型化 resolver

TypeScript 项目里常见问题是把 resolver 的 parentargscontext 都写成 any。短期很快,长期会在 schema 改字段、nullable 调整、context 增加用户信息时出错。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 类型和数据库行通常不是同一形状。schema 中的 ProductreviewsaverageRating,但数据库里的 ProductRow 可能没有。明确映射后,Claude Code 不容易为了满足返回类型而给存储模型乱加字段。

用 DataLoader 解决 N+1

N+1 问题是指先取一个列表,再对列表里的每一行分别取关联数据。例如先取 10 个商品,再查询 10 次评论。DataLoader 会在同一个请求中合并 .load() 调用,并按 key 的顺序返回结果。重点是作用域:它的缓存是请求级缓存,不是 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 层。GraphQL 官方授权文档也强调,生产代码应把授权逻辑交给业务逻辑层,而不是在每个 resolver 里复制条件。这样「当前用户能不能看这个商品」只有一个规则。

限制查询深度和复杂度

GraphQL 的灵活性也是风险来源。深层嵌套、巨大 first、大量 alias、批量请求叠加时,服务器压力会变得不透明。GraphQL Security 文档建议组合使用 trusted documents、分页、深度限制、宽度或批量限制、限流和 query complexity analysis。

下面是一个可复制的 GraphQL.js validation rule。它会读取字面量的 firstlast,把列表大小计入复杂度。变量值在 validation 阶段不一定可用,所以 resolver 仍然要像上面那样把 first 限制在 1 到 50。

// 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 };
}

如果只有第一方客户端,trusted documents 往往更强:生产环境只允许经过审核并登记的操作 ID。公开 API 则要把 validation、HTTP 限流、超时、日志和字段级上限叠加使用。

固定错误格式和测试

GraphQL 响应围绕 dataerrorsextensions 展开。GraphQL 规范区分执行前的 request error 和执行中的 field error,并允许部分 dataerrors 共存。生产环境不要直接返回数据库异常或堆栈,而是统一 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");
  });
});

测试不能只覆盖成功查询。至少要覆盖未登录 mutation、私有数据不返回、非法输入、深度或复杂度拒绝、分页上限、extensions.code 是否稳定。

给 Claude Code 的安全提示词

危险提示词通常很短:「帮我安全一点」「处理 N+1」「补测试」。这些话没有边界。更好的提示词要写清楚可改文件、禁止事项和验收条件。

Improve the GraphQL implementation in this repository.
Allowed files:
- src/graphql/**
- test/graphql.test.ts

Design rules:
- Schema-first; schema.graphql is the public contract
- Use GraphQL Code Generator's Resolvers type; do not add any `any` types
- Keep authorization in repo/service functions, not scattered across resolvers
- Create DataLoader instances inside createContext for each request
- Clamp list fields to first between 1 and 50 and return connection objects
- Add depth and complexity validation
- Shape errors as message, path, extensions.code, extensions.requestId
- Add Vitest coverage for happy path, unauthenticated mutation, private data, and overly deep queries

At the end, report changed files, commands run, remaining risks, and official docs to re-check.

团队使用时,把这些规则整理进 CLAUDE.md 最佳实践,让后续 GraphQL 修改沿用同一套边界。

上线前要挡住的坑

第一,DataLoader 全局共享会让不同用户复用缓存,可能泄露私有数据。第二,只依赖 SDL 默认值限制列表大小不够,resolver 必须再次 clamp。第三,把授权条件散落在 resolver 中,会让另一个 query 路径绕过同一规则。第四,只做深度限制不做复杂度限制,浅层但宽的 query 仍然很贵。

第五,生产环境返回内部错误会泄露表名、堆栈或 schema 线索。第六,CI 不跑 Codegen 会让 schema 和 resolver 类型分离。每次 schema 改动都应同时生成类型并跑测试。

GraphQL 质量也影响收入。会员内容、模板销售、Gumroad 链接、企业线索表单和后台都依赖可靠授权和可控成本。可以先用免费清单固定 Claude Code 检查流程,需要模板时看产品页,团队要设计 schema、授权、测试和 CI 时可从Claude Code 培训与咨询开始。

实际尝试后的结果

Masa 在一个小型商品评论 API 上试过这个流程,最有价值的不是 DataLoader 代码片段,而是把授权边界、first 上限、深度和复杂度、错误格式、测试名称一起放进最初的 Claude Code 请求。以前常常先加 Product.reviews,之后才发现 N+1 和私有数据路径。现在 schema、resolver、validation、test 在同一个差分中移动,评审时间明显缩短。

总结

用 Claude Code 做 GraphQL 开发时,先明确 schema-first 还是 resolver-first,再把 SDL 当作契约,生成类型化 resolver,请求级创建 DataLoader,把授权放进业务逻辑,同时限制深度和复杂度。最后用规范化错误和失败路径测试来证明实现可靠。GraphQL 的灵活性很强,但生产环境需要边界;把这些边界提前交给 Claude Code,输出才会从 demo 变成可评审的实现。

#Claude Code #GraphQL #API development #TypeScript #DataLoader
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。