用 Claude Code 做可靠 GraphQL 开发:SDL、类型化 Resolver、DataLoader 与测试
用 Claude Code 实作 GraphQL API:SDL、类型化 Resolver、N+1、授权边界、复杂度限制、错误与测试。
把 GraphQL API 交给 Claude Code 时,第一步不是列出 User、Product、Review 这些类型。更重要的是先决定:公开契约放在哪里,授权在哪一层执行,N+1 查询如何合并,过深或过贵的操作如何拒绝,错误格式和测试如何证明它可以上线。只说「帮我做 GraphQL」通常能得到能跑的 demo,但也容易留下授权泄漏、慢查询、类型漂移和难排查的错误。
本文把 Claude Code 当作 GraphQL 实作伙伴,而不是一次性代码生成器。SDL 可以理解为「用文本描述 GraphQL API 契约的语言」;resolver 是「为字段取值的函数」;DataLoader 是「在单个请求内把同类读取合并的工具」。示例使用 TypeScript、GraphQL Code Generator、DataLoader、GraphQL.js 和 Vitest。
官方资料以这些为准:Schemas and Types、Pagination、Authorization、Security、GraphQL 规范的 Response Format、DataLoader README、GraphQL Code Generator TypeScript Resolvers 和 Claude 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 结构:edges、node、cursor、pageInfo。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 清洗、私有商品可见性已经处理。把这些约束写进 repo 或 service 层,再让 resolver 调用,才更容易评审。
生成类型化 resolver
TypeScript 项目里常见问题是把 resolver 的 parent、args、context 都写成 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 中的 Product 有 reviews 和 averageRating,但数据库里的 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。它会读取字面量的 first 或 last,把列表大小计入复杂度。变量值在 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 响应围绕 data、errors、extensions 展开。GraphQL 规范区分执行前的 request error 和执行中的 field error,并允许部分 data 与 errors 共存。生产环境不要直接返回数据库异常或堆栈,而是统一 extensions.code 和 requestId。
// 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 变成可评审的实现。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。