Loading learning content...
A GraphQL schema defines what data exists and how it relates. But the schema is just a contract—a promise of what's possible. Resolvers are the functions that fulfill that promise, transforming abstract type definitions into actual data from databases, APIs, caches, and computation.
Every field in a GraphQL schema has a resolver, whether explicit or implicit. When you query user.posts, two resolvers execute: one for user and one for posts. Understanding how resolvers work—their execution model, how they compose, and how to optimize them—is essential for building performant GraphQL APIs.
This is where GraphQL's power meets its complexity. A naive resolver implementation can be devastatingly slow; an optimized one can serve millions of requests per second.
By the end of this page, you will understand resolver architecture and the four resolver arguments. You'll master the N+1 problem and DataLoader pattern, learn context design for cross-cutting concerns, implement robust error handling, and optimize resolver performance for production workloads.
A resolver is a function that returns data for a specific field. When GraphQL executes a query, it traverses the query tree and invokes the resolver for each field, passing the result of parent resolvers to children.
The Resolver Signature:
type Resolver<TResult, TParent, TContext, TArgs> = (
parent: TParent, // Result from parent resolver
args: TArgs, // Arguments passed to this field
context: TContext, // Shared context across all resolvers
info: GraphQLResolveInfo // Detailed query/schema information
) => TResult | Promise<TResult>;
These four arguments provide everything a resolver needs to fulfill its responsibility.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
import { GraphQLResolveInfo } from 'graphql'; // ============================================// ARGUMENT 1: PARENT (also called 'root' or 'source')// ============================================ // The parent argument contains the result returned by the parent resolver.// For root fields (Query/Mutation), this is often null or the rootValue. const resolvers = { Query: { user: async (parent, args, context) => { // parent is typically undefined for root Query fields // unless you pass a rootValue when executing return context.db.users.findUnique({ where: { id: args.id } }); }, }, User: { // For User.posts, 'parent' is the User object returned above posts: async (parent, args, context) => { // parent.id is the user's ID, available from the parent resolver return context.db.posts.findMany({ where: { authorId: parent.id }, take: args.first, }); }, // Computed field using parent data fullName: (parent) => { // No database call needed - derive from parent return `${parent.firstName} ${parent.lastName}`; }, // Default resolver behavior: if no resolver defined, // GraphQL returns parent[fieldName] // So if User has { email: 'a@b.com' }, querying 'email' // returns parent.email automatically },}; // ============================================// ARGUMENT 2: ARGS// ============================================ // Args contains the arguments passed to this specific field type PostsArgs = { first?: number; after?: string; status?: 'DRAFT' | 'PUBLISHED'; orderBy?: { field: string; direction: 'ASC' | 'DESC' };}; const resolvers = { User: { posts: async (parent: User, args: PostsArgs, context) => { const { first = 10, after, status, orderBy } = args; const cursor = after ? { id: decodeCursor(after) } : undefined; return context.db.posts.findMany({ where: { authorId: parent.id, ...(status && { status }), }, take: first + 1, // Fetch one extra for hasNextPage skip: cursor ? 1 : 0, cursor, orderBy: orderBy ? { [orderBy.field]: orderBy.direction.toLowerCase() } : { createdAt: 'desc' }, }); }, },}; // ============================================// ARGUMENT 3: CONTEXT// ============================================ // Context is shared across ALL resolvers in a single request.// It's created once per request and passed everywhere. interface GraphQLContext { // Authentication user: AuthenticatedUser | null; // Data access db: PrismaClient; loaders: DataLoaders; // Request metadata requestId: string; ip: string; // Services cache: CacheService; pubsub: PubSubEngine; logger: Logger;} // Context factory (called per request)export function createContext({ req, res }): GraphQLContext { const user = authenticateRequest(req); const requestId = req.headers['x-request-id'] ?? generateId(); return { user, db: prisma, loaders: createDataLoaders(prisma), // Fresh loaders per request! requestId, ip: req.ip, cache: cacheService, pubsub: pubSubEngine, logger: logger.child({ requestId }), };} // ============================================// ARGUMENT 4: INFO (GraphQLResolveInfo)// ============================================ // Info contains detailed metadata about the query execution.// Rarely used directly but powerful for advanced optimization. const resolvers = { Query: { posts: async (parent, args, context, info: GraphQLResolveInfo) => { // info.fieldName - 'posts' // info.returnType - PostConnection type // info.parentType - Query type // info.path - execution path to this field // info.fieldNodes - AST of this field selection // info.operation - the full operation AST // info.variableValues - variable values for this execution // Advanced: Parse fieldNodes to determine which fields were requested const requestedFields = getRequestedFields(info); // Optimize: Only join author if posts.author was requested const includeAuthor = requestedFields.has('author'); return context.db.posts.findMany({ include: includeAuthor ? { author: true } : undefined, }); }, },}; // Helper to extract requested fields from infofunction getRequestedFields(info: GraphQLResolveInfo): Set<string> { const fields = new Set<string>(); for (const fieldNode of info.fieldNodes) { if (fieldNode.selectionSet) { for (const selection of fieldNode.selectionSet.selections) { if (selection.kind === 'Field') { fields.add(selection.name.value); } } } } return fields;}You don't need to write resolvers for every field. If no resolver is defined, GraphQL uses a default resolver that returns parent[fieldName]. If your database model matches your GraphQL type, most scalar fields 'just work' without explicit resolvers.
The N+1 problem is GraphQL's most infamous performance pitfall. It emerges from the resolver execution model: each resolver executes independently, unaware of other resolvers fetching similar data.
The Scenario:
Consider fetching 10 posts with their authors:
query {
posts(first: 10) {
id
title
author {
id
name
}
}
}
Naive Execution:
At scale, this becomes catastrophic. 100 posts = 101 queries. Nested relationships multiply the problem.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// ============================================// NAIVE IMPLEMENTATION - N+1 PROBLEM// ============================================ const naiveResolvers = { Query: { posts: async (_, args, { db }) => { // Query 1: Fetch all posts console.log('Fetching posts...'); return db.posts.findMany({ take: args.first }); }, }, Post: { author: async (post, _, { db }) => { // Query 2, 3, 4... N+1: Fetch author for EACH post console.log(`Fetching author for post ${post.id}`); return db.users.findUnique({ where: { id: post.authorId } }); }, },}; // Console output for 10 posts:// Fetching posts...// Fetching author for post 1// Fetching author for post 2// Fetching author for post 3// ... (10 separate queries!) // ============================================// WHY GRAPHQL MAKES THIS WORSE// ============================================ // In REST, you'd likely join authors in the /posts endpoint.// In GraphQL, the client might not request 'author', so you can't// preemptively join. Each field resolver acts independently. // The problem compounds with nested data: query DeepNesting { posts(first: 10) { # 1 query author { # 10 queries company { # 10 more queries employees { # Could be 10 * N queries name } } } comments(first: 5) { # 10 queries author { # 50 queries name } } }} // Worst case: 1 + 10 + 10 + 10*N + 10 + 50 = 81+ queries for ONE request! // ============================================// SQL QUERIES GENERATED (without optimization)// ============================================ /*SELECT * FROM posts LIMIT 10;SELECT * FROM users WHERE id = 1;SELECT * FROM users WHERE id = 2;SELECT * FROM users WHERE id = 3;SELECT * FROM users WHERE id = 4;SELECT * FROM users WHERE id = 5;SELECT * FROM users WHERE id = 6;SELECT * FROM users WHERE id = 7;SELECT * FROM users WHERE id = 8;SELECT * FROM users WHERE id = 9;SELECT * FROM users WHERE id = 10;*/ // What we WANT:/*SELECT * FROM posts LIMIT 10;SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);*/// 2 queries instead of 11!The N+1 pattern isn't a flaw in GraphQL—it's a natural consequence of resolver independence. This independence is valuable: it enables composability and separation of concerns. The solution isn't to abandon resolver independence, but to batch and deduplicate at a lower layer.
DataLoader is a utility library that solves N+1 through two mechanisms:
DataLoader works by deferring resolution until the end of the current event loop tick, then executing all pending requests at once.
How DataLoader Works:
Resolver 1: userLoader.load(1) ──┐
Resolver 2: userLoader.load(2) ──┼──► Event loop tick ends
Resolver 3: userLoader.load(1) ──┘ ↓
Batch function called with [1, 2]
(duplicate ID 1 is deduped)
↓
Single query: WHERE id IN (1, 2)
↓
Results dispatched to waiting resolvers
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
import DataLoader from 'dataloader';import { PrismaClient, User, Post, Comment } from '@prisma/client'; // ============================================// BASIC DATALOADER SETUP// ============================================ // Batch function signature: (keys: K[]) => Promise<(V | Error)[]>// CRITICAL: Results MUST be same length as keys, in same order function createUserLoader(db: PrismaClient) { return new DataLoader<string, User | null>(async (userIds) => { console.log(`Batched user fetch: ${userIds.length} users`); // Single query for all users const users = await db.user.findMany({ where: { id: { in: [...userIds] } }, }); // Create lookup map for O(1) access const userMap = new Map(users.map(u => [u.id, u])); // Return in SAME ORDER as input keys // Missing users return null (or throw if required) return userIds.map(id => userMap.get(id) ?? null); });} // ============================================// DATALOADER FACTORY// ============================================ export interface DataLoaders { userById: DataLoader<string, User | null>; postById: DataLoader<string, Post | null>; postsByAuthorId: DataLoader<string, Post[]>; commentsByPostId: DataLoader<string, Comment[]>;} export function createDataLoaders(db: PrismaClient): DataLoaders { return { userById: new DataLoader(async (ids) => { const users = await db.user.findMany({ where: { id: { in: [...ids] } }, }); const map = new Map(users.map(u => [u.id, u])); return ids.map(id => map.get(id) ?? null); }), postById: new DataLoader(async (ids) => { const posts = await db.post.findMany({ where: { id: { in: [...ids] } }, }); const map = new Map(posts.map(p => [p.id, p])); return ids.map(id => map.get(id) ?? null); }), // One-to-many: author -> posts postsByAuthorId: new DataLoader(async (authorIds) => { const posts = await db.post.findMany({ where: { authorId: { in: [...authorIds] } }, }); // Group posts by author const postsByAuthor = new Map<string, Post[]>(); for (const post of posts) { const existing = postsByAuthor.get(post.authorId) ?? []; postsByAuthor.set(post.authorId, [...existing, post]); } // Return array of arrays, in order return authorIds.map(id => postsByAuthor.get(id) ?? []); }), commentsByPostId: new DataLoader(async (postIds) => { const comments = await db.comment.findMany({ where: { postId: { in: [...postIds] } }, orderBy: { createdAt: 'desc' }, }); const commentsByPost = new Map<string, Comment[]>(); for (const comment of comments) { const existing = commentsByPost.get(comment.postId) ?? []; commentsByPost.set(comment.postId, [...existing, comment]); } return postIds.map(id => commentsByPost.get(id) ?? []); }), };} // ============================================// USING DATALOADERS IN RESOLVERS// ============================================ const resolvers = { Query: { posts: async (_, args, { db }) => { return db.post.findMany({ take: args.first }); }, }, Post: { // Use loader instead of direct query author: (post, _, { loaders }) => { return loaders.userById.load(post.authorId); }, comments: (post, _, { loaders }) => { return loaders.commentsByPostId.load(post.id); }, }, Comment: { author: (comment, _, { loaders }) => { return loaders.userById.load(comment.authorId); }, }, User: { posts: (user, _, { loaders }) => { return loaders.postsByAuthorId.load(user.id); }, },}; // ============================================// RESULT: N+1 ELIMINATED// ============================================ // Query for 10 posts with authors:// Before DataLoader: 11 queries// After DataLoader: 2 queries // Query for 10 posts with authors and 5 comments each:// Before DataLoader: 1 + 10 + 50 = 61 queries// After DataLoader: 1 + 1 + 1 + 1 = 4 queries// (posts, unique authors, comments by post, unique comment authors)Critical DataLoader Rules:
Create Fresh Loaders Per Request
// ❌ Wrong: Shared loader across requests
const globalUserLoader = createUserLoader(db);
// ✅ Right: Fresh loader per request
function createContext() {
return { loaders: createDataLoaders(db) };
}
Loaders cache results; sharing across requests means stale data.
Return Values in Order The batch function must return results in the exact same order as input keys.
// Keys: [3, 1, 2]
// Results: [user3, user1, user2] ✅
// Results: [user1, user2, user3] ❌ Wrong order!
Handle Missing Values
If a key has no corresponding value, return null or an Error at that index.
// Keys: [1, 2, 999]
// Results: [user1, user2, null] // 999 doesn't exist
DataLoader caches within a single request. If you load the same user ID twice in one request, the second call returns the cached result. This deduplication is separate from batching—it prevents redundant work even for data already fetched.
Production resolvers need more than data fetching: authentication, authorization, validation, logging, error handling. Resolver composition patterns keep this logic organized and reusable.
Common Cross-Cutting Concerns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
import { GraphQLResolveInfo } from 'graphql'; type ResolverFn<TResult, TParent, TContext, TArgs> = ( parent: TParent, args: TArgs, context: TContext, info: GraphQLResolveInfo) => Promise<TResult> | TResult; // ============================================// PATTERN 1: HIGHER-ORDER RESOLVER// ============================================ // Wrap resolver with authentication checkfunction authenticated<TResult, TParent, TArgs>( resolver: ResolverFn<TResult, TParent, GraphQLContext, TArgs>): ResolverFn<TResult, TParent, GraphQLContext, TArgs> { return async (parent, args, context, info) => { if (!context.user) { throw new AuthenticationError('Must be logged in'); } return resolver(parent, args, context, info); };} // Wrap resolver with authorization checkfunction authorized<TResult, TParent, TArgs>( permission: string, resolver: ResolverFn<TResult, TParent, GraphQLContext, TArgs>): ResolverFn<TResult, TParent, GraphQLContext, TArgs> { return async (parent, args, context, info) => { if (!context.user?.permissions.includes(permission)) { throw new ForbiddenError(`Missing permission: ${permission}`); } return resolver(parent, args, context, info); };} // Combine wrappersconst resolvers = { Mutation: { deletePost: authenticated( authorized('posts:delete', async (_, { id }, { db, user }) => { return db.post.delete({ where: { id } }); }) ), },}; // ============================================// PATTERN 2: COMPOSABLE MIDDLEWARE// ============================================ type Middleware<TContext> = ( resolve: ResolverFn<any, any, TContext, any>, parent: any, args: any, context: TContext, info: GraphQLResolveInfo) => any; function composeMiddleware<TContext>( ...middlewares: Middleware<TContext>[]) { return <TResult, TParent, TArgs>( resolver: ResolverFn<TResult, TParent, TContext, TArgs> ): ResolverFn<TResult, TParent, TContext, TArgs> => { return middlewares.reduceRight( (next, middleware) => (parent, args, context, info) => middleware(next, parent, args, context, info), resolver ); };} // Middleware implementationsconst logMiddleware: Middleware<GraphQLContext> = async ( resolve, parent, args, context, info) => { const start = Date.now(); context.logger.debug(`Resolving ${info.fieldName}`); try { const result = await resolve(parent, args, context, info); context.logger.debug(`${info.fieldName} resolved in ${Date.now() - start}ms`); return result; } catch (error) { context.logger.error(`${info.fieldName} failed: ${error.message}`); throw error; }}; const cacheMiddleware: Middleware<GraphQLContext> = async ( resolve, parent, args, context, info) => { const cacheKey = `${info.parentType}:${info.fieldName}:${JSON.stringify(args)}`; const cached = await context.cache.get(cacheKey); if (cached) { return cached; } const result = await resolve(parent, args, context, info); await context.cache.set(cacheKey, result, { ttl: 300 }); return result;}; // Apply composed middlewareconst withCommonMiddleware = composeMiddleware( logMiddleware, cacheMiddleware); const resolvers = { Query: { popularPosts: withCommonMiddleware(async (_, args, { db }) => { return db.post.findMany({ orderBy: { viewCount: 'desc' }, take: args.first, }); }), },}; // ============================================// PATTERN 3: GRAPHQL SHIELD// ============================================ // graphql-shield provides a declarative permissions layer import { shield, rule, and, or, allow, deny } from 'graphql-shield'; const isAuthenticated = rule()((parent, args, context) => { return context.user !== null;}); const isAdmin = rule()((parent, args, context) => { return context.user?.role === 'ADMIN';}); const isOwner = rule()((parent, args, context) => { return parent.authorId === context.user?.id;}); const permissions = shield({ Query: { '*': allow, // All queries allowed by default adminStats: isAdmin, }, Mutation: { '*': isAuthenticated, // All mutations require auth createUser: allow, // Exception for signup deletePost: or(isAdmin, isOwner), }, User: { email: or(isAdmin, isOwner), // Field-level },}); // Apply to schemaimport { applyMiddleware } from 'graphql-middleware';const schemaWithPermissions = applyMiddleware(schema, permissions);Middleware (like graphql-shield) operates at runtime, invisible to clients. Directives (@auth, @cache) are visible in the schema. Use directives when the behavior is part of the API contract; use middleware for internal concerns. Both can coexist—directives can call middleware internally.
GraphQL error handling differs from REST. Instead of HTTP status codes, errors are returned in a structured errors array alongside partial data. This allows a single response to contain both successful data and errors.
GraphQL Error Structure (per spec):
{
"data": { "user": { "id": "1", "posts": null } },
"errors": [
{
"message": "Failed to fetch posts",
"path": ["user", "posts"],
"locations": [{ "line": 4, "column": 5 }],
"extensions": {
"code": "INTERNAL_ERROR",
"timestamp": "2024-01-15T10:30:00Z"
}
}
]
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
import { GraphQLError } from 'graphql'; // ============================================// CUSTOM ERROR CLASSES// ============================================ export class ApplicationError extends GraphQLError { constructor( message: string, code: string, extensions?: Record<string, any> ) { super(message, { extensions: { code, ...extensions, }, }); }} export class AuthenticationError extends ApplicationError { constructor(message = 'Not authenticated') { super(message, 'UNAUTHENTICATED'); }} export class ForbiddenError extends ApplicationError { constructor(message = 'Forbidden') { super(message, 'FORBIDDEN'); }} export class NotFoundError extends ApplicationError { constructor(resource: string, id: string) { super(`${resource} with ID ${id} not found`, 'NOT_FOUND', { resource, id, }); }} export class ValidationError extends ApplicationError { constructor(message: string, field: string) { super(message, 'VALIDATION_ERROR', { field }); }} export class RateLimitError extends ApplicationError { constructor(retryAfter: number) { super('Rate limit exceeded', 'RATE_LIMITED', { retryAfter, }); }} // ============================================// ERROR HANDLING IN RESOLVERS// ============================================ const resolvers = { Query: { user: async (_, { id }, { db, user }) => { // Authentication check if (!user) { throw new AuthenticationError(); } // Fetch resource const result = await db.user.findUnique({ where: { id } }); // Not found handling if (!result) { throw new NotFoundError('User', id); } // Authorization check if (result.id !== user.id && user.role !== 'ADMIN') { throw new ForbiddenError('Cannot access other users'); } return result; }, }, Mutation: { createPost: async (_, { input }, { db, user }) => { // Validation if (input.title.length < 3) { throw new ValidationError( 'Title must be at least 3 characters', 'title' ); } if (input.title.length > 200) { throw new ValidationError( 'Title must not exceed 200 characters', 'title' ); } try { return await db.post.create({ data: { ...input, authorId: user.id }, }); } catch (error) { // Convert database errors to user-friendly errors if (error.code === 'P2002') { throw new ValidationError('A post with this slug already exists', 'slug'); } throw error; } }, },}; // ============================================// GLOBAL ERROR FORMATTING// ============================================ // In Apollo Server 4+const server = new ApolloServer({ typeDefs, resolvers, formatError: (formattedError, error) => { // Log all errors console.error('GraphQL Error:', error); // Don't expose internal errors in production if (process.env.NODE_ENV === 'production') { // Check if it's a known application error if (error.originalError instanceof ApplicationError) { return formattedError; // Safe to expose } // Hide internal errors return { message: 'An unexpected error occurred', extensions: { code: 'INTERNAL_ERROR' }, }; } // In development, include full error details return { ...formattedError, extensions: { ...formattedError.extensions, stack: error.originalError?.stack, }, }; },}); // ============================================// RESULT TYPES (UNION PATTERN)// ============================================ // For mutations, consider returning union types instead of throwing const typeDefs = ` type CreatePostSuccess { post: Post! } type CreatePostError { message: String! field: String code: ErrorCode! } union CreatePostResult = CreatePostSuccess | CreatePostError type Mutation { createPost(input: CreatePostInput!): CreatePostResult! }`; const resolvers = { Mutation: { createPost: async (_, { input }, { db, user }) => { // Validation returns error type instead of throwing if (input.title.length < 3) { return { __typename: 'CreatePostError', message: 'Title must be at least 3 characters', field: 'title', code: 'VALIDATION_ERROR', }; } const post = await db.post.create({ data: { ...input, authorId: user.id }, }); return { __typename: 'CreatePostSuccess', post, }; }, },}; // Client usage:// mutation {// createPost(input: {...}) {// ... on CreatePostSuccess { post { id } }// ... on CreatePostError { message field }// }// }Distinguish between 'errors' (system/infrastructure failures) and 'user errors' (validation failures, business rule violations). System errors go in the errors array; user errors can be modeled in the schema (via result types or userErrors fields) for type-safe client handling.
Beyond solving N+1 with DataLoader, production GraphQL servers require multiple optimization strategies to handle real-world traffic.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
// ============================================// QUERY COMPLEXITY ANALYSIS// ============================================ import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity'; const complexityRule = createComplexityRule({ maximumComplexity: 1000, estimators: [ // Field-specific complexity from schema extensions fieldExtensionsEstimator(), // Fallback: each field costs 1, lists multiply by 'first' arg simpleEstimator({ defaultComplexity: 1 }), ], onComplete: (complexity) => { console.log(`Query complexity: ${complexity}`); },}); // Schema with complexity hintsconst typeDefs = ` type Query { posts(first: Int = 10): PostConnection! @complexity(value: 1, multipliers: ["first"]) searchPosts(query: String!, first: Int = 10): [Post!]! @complexity(value: 10, multipliers: ["first"]) # Search is expensive } type Post { id: ID! title: String! author: User! @complexity(value: 2) # Triggers loader comments(first: Int = 5): CommentConnection! @complexity(value: 3, multipliers: ["first"]) }`; // ============================================// QUERY DEPTH LIMITING// ============================================ import depthLimit from 'graphql-depth-limit'; const server = new ApolloServer({ typeDefs, resolvers, validationRules: [ depthLimit(10), // Maximum query depth complexityRule, ],}); // ============================================// AUTOMATIC PERSISTED QUERIES (APQ)// ============================================ // Instead of sending full query text, clients send a hash.// Server caches query text by hash. // Client request (first time or cache miss):// POST /graphql// { "extensions": { "persistedQuery": { "sha256Hash": "abc123" } } } // Server responds: "PersistedQueryNotFound"// Client retries with full query:// { "query": "query { ... }", "extensions": { "persistedQuery": { "sha256Hash": "abc123" } } } // Server caches and responds normally.// Subsequent requests only need the hash. // Reduces payload size by 90%+ for large queries. // ============================================// RESPONSE CACHING// ============================================ import responseCachePlugin from '@apollo/server-plugin-response-cache'; const server = new ApolloServer({ plugins: [ responseCachePlugin({ // Cache based on user session sessionId: (context) => context.user?.id ?? null, // Field-level cache hints // Set via @cacheControl directive or programmatically }), ],}); // Schema cache hintsconst typeDefs = ` type Query { # Public, cacheable for 5 minutes publicPosts: [Post!]! @cacheControl(maxAge: 300, scope: PUBLIC) # Private, cacheable per-user for 1 minute myFeed: [Post!]! @cacheControl(maxAge: 60, scope: PRIVATE) # No caching realTimeStats: Stats! @cacheControl(maxAge: 0) }`; // ============================================// SELECTIVE LOADING WITH INFO// ============================================ import { parseResolveInfo, simplifyParsedResolveInfoFragmentType } from 'graphql-parse-resolve-info'; const resolvers = { Query: { posts: async (_, args, { db }, info) => { // Parse info to determine requested fields const parsedInfo = parseResolveInfo(info); const simplified = simplifyParsedResolveInfoFragmentType(parsedInfo, info.returnType); // Determine which relations to eagerly load const include: any = {}; if (simplified.fields.author) { include.author = true; } if (simplified.fields.category) { include.category = true; } if (simplified.fields.tags) { include.tags = true; } // Only include relations that are actually requested return db.post.findMany({ take: args.first, include: Object.keys(include).length > 0 ? include : undefined, }); }, },}; // ============================================// STREAMING WITH @defer AND @stream// ============================================ // @defer: Return main content immediately, stream expensive parts laterquery GetUserProfile { user(id: "1") { id name ... @defer { # Expensive recommendation system, streamed after initial response recommendations { id title } } }} // @stream: Stream list items as they become availablequery GetNotifications { notifications @stream(initialCount: 5) { id message createdAt }}| Problem | Solution | When to Use |
|---|---|---|
| N+1 queries | DataLoader | Always - fundamental requirement |
| Large query payloads | APQ (Persisted Queries) | High-traffic APIs, mobile clients |
| Resource exhaustion | Complexity + Depth limits | Public APIs, untrusted clients |
| Slow queries | Response caching | Immutable/slowly-changing data |
| Unnecessary data loading | Info parsing | Complex schemas with optional relations |
| Initial response latency | @defer/@stream | Large responses with mixed priority data |
No single optimization is sufficient. Production APIs need DataLoader (N+1), complexity limits (resource protection), caching (performance), and query analysis (monitoring). Combine these layers for robust performance.
Testing GraphQL resolvers requires different strategies than testing REST endpoints. You can test at multiple levels:
Key Testing Considerations:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
import { createTestClient } from 'apollo-server-testing';import { ApolloServer } from '@apollo/server';import { describe, it, expect, beforeEach, vi } from 'vitest'; // ============================================// UNIT TESTING RESOLVERS// ============================================ describe('User resolvers', () => { const mockDb = { user: { findUnique: vi.fn(), findMany: vi.fn(), }, }; const mockLoaders = { userById: { load: vi.fn() }, postsByAuthorId: { load: vi.fn() }, }; const createContext = (user = null) => ({ user, db: mockDb, loaders: mockLoaders, }); beforeEach(() => { vi.clearAllMocks(); }); describe('Query.user', () => { it('returns user when found', async () => { const mockUser = { id: '1', name: 'Test User' }; mockDb.user.findUnique.mockResolvedValue(mockUser); const result = await resolvers.Query.user( null, { id: '1' }, createContext({ id: 'admin', role: 'ADMIN' }), {} as any ); expect(result).toEqual(mockUser); expect(mockDb.user.findUnique).toHaveBeenCalledWith({ where: { id: '1' } }); }); it('throws NotFoundError when user not found', async () => { mockDb.user.findUnique.mockResolvedValue(null); await expect( resolvers.Query.user( null, { id: 'nonexistent' }, createContext({ id: 'admin', role: 'ADMIN' }), {} as any ) ).rejects.toThrow(NotFoundError); }); it('throws AuthenticationError when not logged in', async () => { await expect( resolvers.Query.user( null, { id: '1' }, createContext(null), // No user {} as any ) ).rejects.toThrow(AuthenticationError); }); }); describe('User.posts', () => { it('uses DataLoader for batch efficiency', async () => { const mockPosts = [{ id: 'p1', title: 'Post 1' }]; mockLoaders.postsByAuthorId.load.mockResolvedValue(mockPosts); const result = await resolvers.User.posts( { id: 'u1' }, // Parent user {}, createContext({ id: 'u1' }), {} as any ); expect(mockLoaders.postsByAuthorId.load).toHaveBeenCalledWith('u1'); expect(result).toEqual(mockPosts); }); });}); // ============================================// INTEGRATION TESTING WITH APOLLO SERVER// ============================================ describe('GraphQL API Integration', () => { let server: ApolloServer; let testDb: PrismaClient; beforeAll(async () => { testDb = new PrismaClient({ datasources: { db: { url: process.env.TEST_DATABASE_URL } }, }); server = new ApolloServer({ typeDefs, resolvers, }); await server.start(); }); afterAll(async () => { await server.stop(); await testDb.$disconnect(); }); beforeEach(async () => { // Reset database state await testDb.post.deleteMany(); await testDb.user.deleteMany(); }); it('fetches posts with authors (integration)', async () => { // Seed test data const user = await testDb.user.create({ data: { name: 'Author', email: 'author@test.com' }, }); await testDb.post.createMany({ data: [ { title: 'Post 1', authorId: user.id }, { title: 'Post 2', authorId: user.id }, ], }); const response = await server.executeOperation({ query: ` query GetPosts { posts(first: 10) { id title author { id name } } } `, variables: {}, }, { contextValue: createContext({ id: user.id }), }); expect(response.body.kind).toBe('single'); expect(response.body.singleResult.errors).toBeUndefined(); expect(response.body.singleResult.data.posts).toHaveLength(2); expect(response.body.singleResult.data.posts[0].author.name).toBe('Author'); }); it('handles authentication errors correctly', async () => { const response = await server.executeOperation({ query: ` mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { ... on CreatePostSuccess { post { id } } ... on CreatePostError { message code } } } `, variables: { input: { title: 'Test', content: 'Content' } }, }, { contextValue: createContext(null), // Unauthenticated }); expect(response.body.singleResult.errors).toBeDefined(); expect(response.body.singleResult.errors[0].extensions.code).toBe('UNAUTHENTICATED'); });}); // ============================================// TESTING DATALOADER BEHAVIOR// ============================================ describe('DataLoader batching', () => { it('batches multiple loads into single query', async () => { const db = { user: { findMany: vi.fn().mockResolvedValue([ { id: '1', name: 'User 1' }, { id: '2', name: 'User 2' }, { id: '3', name: 'User 3' }, ]), }, }; const loader = createUserLoader(db as any); // Simulate multiple resolver calls const results = await Promise.all([ loader.load('1'), loader.load('2'), loader.load('3'), loader.load('1'), // Duplicate - should be deduped ]); // Should be called only once with all unique IDs expect(db.user.findMany).toHaveBeenCalledTimes(1); expect(db.user.findMany).toHaveBeenCalledWith({ where: { id: { in: ['1', '2', '3'] } }, // Deduped }); expect(results[0]).toEqual({ id: '1', name: 'User 1' }); expect(results[3]).toBe(results[0]); // Same reference (cached) });});Create a test context factory that matches your production context shape. This factory should allow overriding any part of the context for specific test scenarios (authenticated/unauthenticated, different roles, mocked services).
Resolvers are where GraphQL's abstract schema meets concrete data. Mastering resolver patterns is essential for building performant, secure, and maintainable GraphQL APIs.
What's Next:
With query mechanics, schema design, and resolvers understood, we can now compare GraphQL directly with REST. When does GraphQL provide genuine advantages? When does REST remain the better choice? The next page provides a rigorous analysis of the trade-offs.
You now understand resolver architecture—from the four arguments to DataLoader batching, middleware composition, error handling, and performance optimization. You can build resolvers that are efficient, secure, and testable. Next, we'll compare GraphQL and REST trade-offs.