Claude Code GraphQL CLAUDE.md AI Coding Apollo DataLoader 2026

CLAUDE.md for GraphQL APIs: Schema Design, Resolver Rules, and N+1 Query Prevention (2026)

The Prompt Shelf ·

GraphQL’s core pitch is that a client asks for exactly the fields it needs and gets exactly that back. That flexibility is also where an unguided agent does the most damage: a resolver that looks correct in isolation — read one field, return one value — can silently become the thing that fires hundreds of database queries for a single request, and nothing about the code makes that obvious until it’s already in production.

Out of the box, Claude Code or Cursor on a GraphQL codebase will:

  • Write a nested field resolver (User.posts, Post.author) that queries the database directly instead of batching through a per-request DataLoader, which works for one item and becomes N+1 the moment a list resolver returns more than one
  • Mix schema-first SDL files with hand-typed code-first resolvers in the same project, or invent a new pattern entirely, because nothing states which convention the project actually follows
  • Throw a plain Error from a mutation resolver instead of following the project’s payload-error pattern, collapsing a field-level validation failure into an opaque top-level errors array entry
  • Return a flat array for a list field instead of the project’s cursor-based Connection type, breaking pagination for every client already written against the Relay-style shape
  • Add a new type or field without a description, or expose an internal database column name directly as a GraphQL field, leaking implementation detail into the public contract

None of these are GraphQL being fragile. They’re what happens when an agent treats a GraphQL resolver like a regular function — correct for the input it’s tested against, silent about the traversal pattern that breaks it at scale.

Why GraphQL Needs Its Own CLAUDE.md Section

GraphQL sits underneath whatever HTTP framework hosts it — Apollo Server standalone, a NestJS GraphQLModule, Express with graphql-http, a Next.js Route Handler. Framework-level CLAUDE.md files cover routing and module wiring; the schema-resolver contract that determines whether the API is fast or accidentally quadratic gets a line or two, if that.

The N+1 problem doesn’t fail a test. A resolver that queries db.comments.findMany({ where: { postId: parent.id } }) returns correct data every time it’s called. The bug is in the multiplication: a posts query returning 50 posts, each resolving its own comments field, turns into 50 separate database round trips instead of one batched query. It passes code review because the individual resolver looks right, and it passes a manual test because a manual test rarely queries 50 items at once.

Schema-first and code-first are both legitimate, and mixing them is the actual risk. A project using NestJS’s code-first decorators (@ObjectType(), @Field()) generates its schema from TypeScript classes; a project using SDL-first .graphql files with GraphQL Code Generator does the reverse. An agent with no stated convention picks whichever pattern it’s seen more of recently, and a codebase with both — a .graphql file nobody updates next to decorator-based types that already diverged from it — is worse than either pattern alone.

Every field is a permanent public contract the moment it ships, more so than a REST endpoint. GraphQL doesn’t version endpoints; a field that’s wrong gets deprecated (@deprecated(reason: "...")) and left in place, not silently changed, because any client anywhere might already be selecting it. An agent that doesn’t know this will “fix” a field’s type or rename it directly, breaking every existing query that selects it.

Schema Design: Pick One Convention, State It

## Schema Convention

This project is [schema-first: SDL files in \`schema/**/*.graphql\`, types
generated via GraphQL Code Generator | code-first: NestJS decorators
(\`@ObjectType\`, \`@Field\`, \`@Resolver\`), schema generated to
\`schema.gql\` on build].

Never hand-write a resolver type that doesn't match the schema — [regenerate
types after any \`.graphql\` change with \`npm run codegen\` | let the
decorators be the single source of truth; don't also maintain a parallel
\`.graphql\` file].

Naming and shape conventions matter more in GraphQL than most APIs, because the schema is the interface documentation:

type Query {
  post(id: ID!): Post
  posts(first: Int = 20, after: String): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

type Post {
  id: ID!
  title: String!
  createdAt: DateTime!
  author: User!
  comments(first: Int = 20, after: String): CommentConnection!
}

# Relay-style connection, not a flat array — see Pagination below
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}
type PostEdge {
  node: Post!
  cursor: String!
}

input CreatePostInput {
  title: String!
  body: String!
}

type CreatePostPayload {
  post: Post
  errors: [UserError!]!
}

The rule worth stating explicitly: createdAt, not created or date_created — camelCase fields, descriptive names, and every mutation returns a *Payload type with an errors array rather than the bare entity. An agent extending this schema without the rule will follow whichever nearby field happens to be named inconsistently, and inconsistency compounds because clients start depending on both spellings.

Resolvers: DataLoader Is Not Optional for Non-Root Fields

This is the highest-cost gap to leave undocumented, because the failure mode is invisible in development and expensive in production.

## Resolvers

Root \`Query\`/\`Mutation\` resolvers may query the database directly.
Every non-root field resolver that loads related data (\`Post.author\`,
\`User.posts\`, \`Post.comments\`) MUST go through a per-request
\`DataLoader\` — never a direct \`db.*\` call inside a nested resolver.

Loaders are constructed fresh per request inside the context factory, not
at module scope — a module-scoped loader caches across requests and leaks
data between users.
// context.ts — new loaders every request
import DataLoader from 'dataloader';

export function createContext(): Context {
  return {
    db,
    loaders: {
      userById: new DataLoader<string, User>(async (ids) => {
        const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
        const byId = new Map(users.map((u) => [u.id, u]));
        // must return in the same order as ids, null for missing
        return ids.map((id) => byId.get(id) ?? null);
      }),
      commentsByPostId: new DataLoader<string, Comment[]>(async (postIds) => {
        const comments = await db.comment.findMany({ where: { postId: { in: [...postIds] } } });
        const byPost = new Map<string, Comment[]>();
        for (const c of comments) {
          (byPost.get(c.postId) ?? byPost.set(c.postId, []).get(c.postId)!).push(c);
        }
        return postIds.map((id) => byPost.get(id) ?? []);
      }),
    },
  };
}
// resolvers/post.ts
export const Post = {
  author: (post: Post, _args: unknown, ctx: Context) =>
    ctx.loaders.userById.load(post.authorId),
  comments: (post: Post, _args: unknown, ctx: Context) =>
    ctx.loaders.commentsByPostId.load(post.id),
};

Stating the rule as a hard boundary — non-root resolvers never call the database directly — gives Claude Code something to check itself against, rather than trusting that the general concept of “batching” comes up unprompted. DataLoader’s own contract matters too: the returned array must be the same length and order as the input keys, with null (not omission) for missing items, or a client requesting three posts’ authors gets misaligned results with no error.

Error Handling: Payload Pattern for Expected Failures

## Errors

Mutations return a \`*Payload\` type with an \`errors: [UserError!]!\` field
for expected failures (validation, not found, permission denied) — the
client checks \`payload.errors\` before reading \`payload.post\`.

Throw a \`GraphQLError\` with an \`extensions.code\` only for failures the
client can't recover from mid-flow — auth token expired, downstream
service unavailable. Never throw for input validation; that belongs in
the payload.
type UserError = {
  field: string;
  message: string;
};

async function createPost(input: CreatePostInput, ctx: Context) {
  if (input.title.trim().length === 0) {
    return { post: null, errors: [{ field: 'title', message: 'Title cannot be empty' }] };
  }
  const post = await ctx.db.post.create({ data: input });
  return { post, errors: [] };
}
// only for unrecoverable, unexpected failures
import { GraphQLError } from 'graphql';

if (!ctx.user) {
  throw new GraphQLError('Not authenticated', {
    extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },
  });
}

The distinction matters because GraphQL’s top-level errors array is disconnected from the field that failed — a client parsing it has to string-match a message to know which part of a multi-field mutation broke. A payload’s errors array is scoped to that one mutation and typed, so payload.errors[0].field === 'title' is something a UI can branch on directly. Reserve the thrown GraphQLError for cases with no partial result to return alongside an error.

Pagination: Connections, Not Flat Arrays

## Pagination

Every list field that can return more than a fixed small set uses a
Relay-style \`Connection\` (\`edges { node cursor } pageInfo\`), not a bare
\`[Type!]!\`. Cursor is an opaque, base64-encoded string — never a raw
offset or database ID a client could depend on the internal shape of.
function encodeCursor(id: string): string {
  return Buffer.from(`cursor:${id}`).toString('base64');
}
function decodeCursor(cursor: string): string {
  return Buffer.from(cursor, 'base64').toString('utf-8').replace('cursor:', '');
}

async function posts(_root: unknown, args: { first: number; after?: string }, ctx: Context) {
  const afterId = args.after ? decodeCursor(args.after) : undefined;
  const rows = await ctx.db.post.findMany({
    take: args.first + 1,
    ...(afterId && { cursor: { id: afterId }, skip: 1 }),
    orderBy: { id: 'asc' },
  });
  const hasNextPage = rows.length > args.first;
  const items = hasNextPage ? rows.slice(0, -1) : rows;
  return {
    edges: items.map((post) => ({ node: post, cursor: encodeCursor(post.id) })),
    pageInfo: { hasNextPage, endCursor: items.length ? encodeCursor(items.at(-1)!.id) : null },
  };
}

Retrofitting a flat array into a Connection later is a breaking change for every client already selecting that field — deciding this once, up front, and stating it in CLAUDE.md avoids an agent adding a new list field as a plain array because that’s the simpler thing to write in isolation.

Federation: When One Schema Isn’t One Service

For projects split across multiple services, state whether federation is in play — an agent unaware of it will treat a @key directive or an unresolved reference type as a mistake to “fix”:

## Federation

This schema is a subgraph in an Apollo Federation supergraph. Types shared
across services carry \`@key(fields: "id")\`; a type referenced but not
owned by this subgraph is declared as an extension with only the fields
this service resolves — do not add unrelated fields to an extended type,
they belong in the owning subgraph.
# In the reviews subgraph — Product is owned by the catalog subgraph
type Product @key(fields: "id") {
  id: ID!
  reviews: [Review!]!
}

Federation is easy to misdiagnose as duplication (“this type is defined in two places”) without the context that each subgraph deliberately declares only the fields it resolves. Stating this once prevents an agent from merging the two definitions or deleting one.

Client Integration and Codegen

## Client

Queries and mutations live in \`.graphql\` files under \`src/graphql/\`, one
file per operation. Run \`npm run codegen\` after any query change — never
hand-write the generated \`useXQuery\`/\`useXMutation\` hooks or their
TypeScript types.
// src/graphql/GetPost.graphql generates a typed hook via graphql-codegen
import { useGetPostQuery } from '../generated/graphql';

function PostPage({ id }: { id: string }) {
  const { data, loading, error } = useGetPostQuery({ variables: { id } });
  if (loading) return <Spinner />;
  if (error) return <ErrorState error={error} />;
  return <Post post={data!.post} />;
}

Codegen is the piece an agent skips most often when it’s not stated as a rule — it’s faster to hand-write a useQuery<PostData>(GET_POST) call that compiles fine, and the divergence between that hand-typed shape and the actual schema only shows up when the schema changes and nothing re-checks the hand-written type.

Testing Resolvers Without a Running Server

## Testing

Test resolver functions directly with a mocked context and DataLoader —
not through a running Apollo Server instance for unit-level coverage.
Reserve \`executeOperation\`-based tests for a small number of
integration tests covering the full request path.
import { describe, it, expect, vi } from 'vitest';
import { Post } from '../resolvers/post';

describe('Post.author resolver', () => {
  it('loads via the userById DataLoader', async () => {
    const load = vi.fn().mockResolvedValue({ id: 'u1', name: 'Ada' });
    const ctx = { loaders: { userById: { load } } } as any;
    const result = await Post.author({ id: 'p1', authorId: 'u1' } as any, {}, ctx);
    expect(load).toHaveBeenCalledWith('u1');
    expect(result.name).toBe('Ada');
  });
});

This mirrors the same principle that keeps resolver logic thin in the first place: if a resolver is just a call into a loader or a service function, testing it doesn’t require spinning up a schema, a server, and a database.

CLAUDE.md vs AGENTS.md for a GraphQL Project

AGENTS.md holds the schema convention, the DataLoader-for-non-root-resolvers rule, the payload-error pattern, and pagination shape — tool-agnostic conventions that apply whether Claude Code, Cursor, or Codex is generating the resolver. CLAUDE.md adds Claude Code-specific instructions on top: whether to run npm run codegen automatically after touching a .graphql file, how verbose to be when explaining a schema change.

AGENTS.md       → schema convention, DataLoader rule, error payload pattern, pagination shape, federation notes
CLAUDE.md       → "run `npm run codegen` after editing schema/**/*.graphql", verbosity preferences

If the GraphQL server is mounted inside an existing NestJS or Express AGENTS.md, add a ## GraphQL section rather than a separate file — the API-layer conventions here are compact enough to sit alongside the framework’s routing rules.

Complete CLAUDE.md Template

# CLAUDE.md

## Project Overview
[Apollo Server / NestJS GraphQLModule / graphql-yoga] serving a GraphQL API.
[Schema-first with codegen | Code-first with decorators]. TypeScript.

## Commands
- Dev server: [framework-specific command]
- Codegen: `npm run codegen` (run after any `.graphql` or schema type change)
- Run tests: [test command]

## Schema Convention
[State schema-first or code-first, and where the source of truth lives.]
Fields use camelCase. Every mutation returns a `*Payload` type with an
`errors: [UserError!]!` field rather than the bare entity.

## Resolvers
Root `Query`/`Mutation` resolvers may query the database directly. Every
non-root field resolver that loads related data goes through a per-request
`DataLoader` built in the context factory — never a direct DB call inside
a nested resolver.

## Errors
Expected failures (validation, not found, permission) go in a mutation's
`errors` payload field. Throw `GraphQLError` with `extensions.code` only
for unrecoverable failures with no partial result to return.

## Pagination
List fields that can grow use a Relay-style `Connection`
(`edges { node cursor } pageInfo`), never a bare array. Cursors are opaque
base64 strings, not raw IDs or offsets.

## Federation
[If applicable: which types this subgraph owns vs extends, and the `@key`
fields shared across services.]

## Client
Operations live in `.graphql` files under `src/graphql/`. Run `npm run
codegen` after any change — never hand-write the generated hooks or types.

## Testing
Test resolvers directly with a mocked context/DataLoader for unit
coverage. Reserve full-server `executeOperation` tests for a small
integration suite.

## What to Avoid
- A non-root resolver calling the database directly instead of a DataLoader.
- A DataLoader constructed outside the per-request context factory.
- Mixing schema-first `.graphql` files with hand-typed code-first resolvers.
- Throwing a plain `Error`/`GraphQLError` for expected validation failures.
- A flat array for a list field that should be a `Connection`.
- Hand-written query hooks or types instead of running codegen.

How Different AI Tools Read These Files

Claude Code reads CLAUDE.md/AGENTS.md automatically, merging any it finds walking up the directory tree. The DataLoader rule and the schema-convention statement are worth putting near the top — they’re the two decisions most likely to get silently violated when an agent extends an existing resolver rather than reasoning about the schema from scratch.

Cursor reads .cursorrules by default, with optional native AGENTS.md support. A GraphQL-specific .cursorrules example — schema design, resolvers, DataLoader, subscriptions, and caching — is worth comparing directly against the rules above; see the GraphQL + Apollo entry in our gallery for a full community-maintained version of this pattern.

GitHub Copilot doesn’t read AGENTS.md natively — it reads .github/copilot-instructions.md. The DataLoader and payload-error rules are worth duplicating there specifically, since they’re the highest-cost failure modes and Copilot otherwise has no visibility into them.

OpenAI Codex reads AGENTS.md natively with the same directory-tree resolution as Claude Code, so the template above applies unmodified.

Putting It Together

GraphQL’s flexibility for clients is exactly what makes an unguided resolver dangerous for the server: nothing in a single resolver’s code signals whether it’s about to be called once or five hundred times in the same request. The rules that matter most are the ones invisible in a diff — DataLoader for every non-root resolver, one schema convention instead of two competing ones, and cursor-based pagination decided before the first list field ships, not retrofitted after a client already depends on a flat array.

Start from the template above, state whether the project is schema-first or code-first, and fill in the federation section only if the schema is actually split across services — a single-service API doesn’t need @key directives cluttering the rules for a pattern that doesn’t apply.

Real-world examples of AI rules files that touch GraphQL and adjacent API layers are in our gallery — see GraphQL + Apollo for a full schema-resolver-DataLoader .cursorrules example, and React + GraphQL (Apollo Client) for the client-side consumption pattern.

FAQ

Does a GraphQL API need its own CLAUDE.md section, separate from the framework it’s mounted on?

Yes. The schema-resolver-DataLoader contract is the same regardless of whether GraphQL sits inside NestJS, Express, or a standalone Apollo Server, so it’s worth documenting once rather than folded into a framework guide that only mentions it in passing.

Why does Claude Code write resolvers that query the database directly instead of using DataLoader?

A direct query works correctly for a single item and only becomes a performance bug when a list resolver fans it out across every returned item. Without a stated rule that non-root resolvers must go through a per-request DataLoader, there’s no signal that the code is a latent N+1 problem rather than a working feature.

What’s the difference between schema-first and code-first GraphQL, and which should CLAUDE.md specify?

Schema-first defines the API in SDL and generates types from it; code-first defines types as decorated TypeScript classes and generates the SDL. Both work, but CLAUDE.md needs to state which one the project uses — otherwise an agent defaults to whichever pattern it’s seen most recently, which often doesn’t match the existing schema.

Should GraphQL errors be thrown or returned in the response payload?

Expected, field-level failures (validation, not found, permission) belong in a mutation’s typed errors payload field, which a client can branch on directly. Thrown GraphQLErrors are for unrecoverable failures with no partial result to return alongside them.

Does GraphQL still make sense next to tRPC or REST in 2026?

GraphQL’s advantage is a single endpoint serving multiple, differently-shaped clients with client-driven field selection. tRPC covers the same type-safety goal with less setup for a TypeScript-only client and server; projects with multiple heterogeneous clients or a public API surface still reach for GraphQL over either alternative.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules