Practical GraphQL Development with Claude Code: SDL, Typed Resolvers, DataLoader, and Tests
Build GraphQL APIs with Claude Code: SDL, typed resolvers, N+1, auth, demand control, errors, and tests.
When you ask Claude Code to build a GraphQL API, the first decision is not which object types to add. The first decision is where the contract lives, where authorization is enforced, how N+1 reads are batched, how deep or expensive operations are rejected, and how errors and tests prove the API is safe to publish. A vague request like “make this GraphQL” can produce a working demo while leaving production risks in the schema, resolvers, and data layer.
This guide treats Claude Code as a GraphQL implementation partner, not a one-shot code generator. In plain terms, SDL means the schema definition language, the language-neutral text contract for a GraphQL API. A resolver is the function that returns a value for a GraphQL field. DataLoader is a request-scoped batching utility that helps prevent repeated backend reads while a single GraphQL operation executes.
Use primary sources as the baseline: GraphQL Schemas and Types, Pagination, Authorization, Security, the GraphQL specification’s Response Format, the DataLoader README, GraphQL Code Generator TypeScript Resolvers, and the Claude Code docs. For adjacent ClaudeCodeLab material, pair this with API development, API testing, and prompt techniques.
Choose Schema-First or Resolver-First
GraphQL projects usually start in one of two ways. Schema-first means you write SDL first, treat it as the public contract, then implement resolvers and tests against that contract. Resolver-first means you start from existing domain services, TypeScript functions, or database access, then shape the schema around the behavior that already exists. With Claude Code, schema-first is usually easier to review for a new public API, a cross-team API, or a mobile API. Resolver-first is often better for migrating an existing REST or service layer because the real behavior already lives in code.
Three use cases make the choice concrete. For a SaaS admin dashboard, schema-first keeps team, billing, and audit-log boundaries visible before implementation. For a mobile app API, GraphQL can reduce round trips, but you need pagination, operation limits, and stable error shapes before release. For an internal analytics tool, resolver-first can be practical because existing data services matter more than a polished external contract, but authorization still belongs in shared business logic.
Give Claude Code the design choice before it edits files.
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
This prompt fixes the engineering boundaries before any library-specific detail. Apollo Server, GraphQL Yoga, Mercurius, or a custom GraphQL.js server can all be reasonable choices in different repositories, but contract, authorization, demand control, and tests are the parts Claude Code must not improvise.
Put SDL at the Center
The next SDL defines a small product review API. It follows the connection shape described by GraphQL’s pagination guidance: edges, node, cursor, and pageInfo. A nested field such as Product.reviews is useful, but it is also where N+1 queries and expensive recursive operations start. That is why the later code adds DataLoader and demand control instead of stopping at the schema.
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!
}
Do not treat GraphQL types as your full validation layer. rating: Int! says the value must be an integer, but it does not say the rating must be between 1 and 5, that review text should be capped, that unsafe HTML should be sanitized, or that a private product must stay hidden. Those rules belong in the resolver’s service layer, and Claude Code should be asked to name them.
Generate Typed Resolvers
The most common TypeScript GraphQL shortcut is to accept parent, args, and context as any. It feels fast, but every schema change becomes a runtime risk. GraphQL Code Generator’s typescript-resolvers plugin can generate resolver signatures from SDL so arguments, parent rows, return values, and context stay aligned.
// 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"
}
}
The mappers setting matters because GraphQL types and storage rows are rarely identical. A Product in the schema has reviews and averageRating; a ProductRow from a database usually does not. Mapping those shapes keeps Claude Code from inventing storage fields just to satisfy a GraphQL return type.
Fix N+1 with Request-Scoped DataLoader
The N+1 problem happens when one list query is followed by one backend read per row. Fetch 10 products, then fetch reviews 10 separate times, and a harmless-looking GraphQL query becomes noisy and slow. DataLoader batches related .load() calls and returns values in the same order as the keys. The important rule is scope: DataLoader’s memoization cache is for a single request, not a Redis replacement and not a global cross-user cache.
// 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;
},
},
};
Authorization is intentionally concentrated inside repo.productById, repo.products, and related functions. GraphQL’s authorization guidance recommends delegating authorization to business logic rather than duplicating checks in every resolver. That keeps “can this viewer see this product?” as one rule instead of a collection of almost-identical resolver conditions.
Limit Depth and Complexity
GraphQL lets clients ask for exactly the fields they need. The same flexibility can create denial-of-service risk when an operation is deeply nested, requests huge lists, uses many aliases, or batches several heavy operations. GraphQL’s security guidance describes a layered posture: trusted documents, paginated fields, depth limiting, breadth and batch limiting, rate limiting, and query complexity analysis.
The following validation rule is intentionally small and copy-pasteable. It accounts for literal first and last arguments. Variable values are not always available during validation, so resolvers must still clamp list arguments, as the resolver code does with 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 };
}
For first-party applications, trusted documents are often stronger than ad hoc operation limits because production clients can send operation IDs from an allowlist rather than arbitrary GraphQL text. For public APIs, pair validation with HTTP rate limits, request timeouts, list caps, and logging. Ask Claude Code for a threat table, not just a depth-limit snippet.
Standardize Errors and Tests
GraphQL responses revolve around data, errors, and optional extensions. The GraphQL specification distinguishes request errors that happen before execution from field errors that happen during execution, and it allows partial data with field errors. In production, do not return database exceptions or stack traces directly. Use a stable extensions.code and a requestId for support.
// 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");
});
});
Tests should cover more than the happy path. Add cases for unauthenticated mutations, private data, invalid input ranges, depth and complexity rejection, pagination caps, and expected extensions.code values. Tell Claude Code those exact cases; otherwise it will often test only the query that demonstrates the feature.
Use Safe Claude Code Prompts
Dangerous GraphQL prompts sound like “make it secure”, “handle N+1”, or “add tests”. Those words are too broad. Claude Code needs file scope, design constraints, verification commands, and explicit non-goals.
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.
This prompt gives Claude Code a finish line. For team use, move the same rules into CLAUDE.md best practices so future GraphQL edits keep the same boundaries.
Pitfalls to Block Before Release
The first pitfall is a global DataLoader. A cross-request loader can leak cached private data between users. The second is trusting SDL defaults for list size. Clients can still send large first values, so resolvers must clamp. The third is scattering authorization across resolvers. If another query reaches the same data through a different path, the conditions drift.
The fourth pitfall is depth limiting without complexity limits. A shallow operation can still be expensive if it uses many aliases or wide lists. The fifth is returning raw internal errors. Developer hints are useful locally, but production responses should not reveal table names, stack traces, or schema exploration details. The sixth is leaving Codegen out of CI. If schema and resolver types are not regenerated together, type safety becomes ceremonial.
GraphQL quality also affects revenue. Paid content, templates, Gumroad paths, lead forms, and admin dashboards all depend on reliable authorization and predictable API cost. Start with the free checklist, use products and templates when you need reusable prompts and review rules, and use Claude Code training or consultation when your team needs GraphQL schema design, authorization, tests, and CI reviewed against a real repository.
Hands-On Result
When Masa tested this flow on a small product review API, the biggest improvement was not the DataLoader snippet itself. The improvement came from putting authorization boundaries, first caps, depth and complexity rules, error shape, and test names into the initial Claude Code request. In an earlier draft, Product.reviews was added first and N+1 was discovered later. With this structure, schema, resolvers, validation, and tests moved together, so review time dropped and private-data checks were much clearer.
Summary
For GraphQL development with Claude Code, choose schema-first or resolver-first deliberately, keep SDL as the contract, generate typed resolvers, create DataLoader instances per request, put authorization in business logic, and limit both depth and complexity. Then standardize GraphQL errors and test the failure paths, not only the successful query.
GraphQL is powerful because it is flexible. That same flexibility needs guardrails before production. Give Claude Code the boundaries, forbidden shortcuts, and verification commands up front, and the output becomes reviewable implementation instead of a polished demo.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.