Use when implementing GraphQL resolvers with resolver functions, context management, DataLoader batching, error handling, authentication, and testing strategies.
Read-only skill
Additional assets for this skill
This skill cannot use any tools. It operates in read-only mode without the ability to modify files or execute commands.
Apply resolver implementation patterns to create efficient, maintainable GraphQL servers. This skill covers resolver function signatures, execution chains, context management, DataLoader patterns, async handling, authentication, and testing strategies.
Every resolver function receives four arguments: parent, args, context, and info. Understanding these arguments is fundamental to writing effective resolvers.
type ResolverFn = (
parent: any,
args: any,
context: any,
info: GraphQLResolveInfo
) => any;
const resolvers = {
Query: {
// parent: root value (usually undefined for Query)
// args: arguments passed to the query
// context: shared context object
// info: execution information
user: async (parent, args, context, info) => {
const { id } = args;
const { dataSources, user } = context;
// Use context to access data sources and auth info
return dataSources.userAPI.getUserById(id);
},
posts: async (parent, args, context, info) => {
const { limit, offset } = args;
// Access requested fields from info
const fields = info.fieldNodes[0].selectionSet.selections
.map(s => s.name.value);
return context.dataSources.postAPI.getPosts({
limit,
offset,
fields
});
}
}
};
Field resolvers define how to resolve individual fields on a type. The parent argument contains the resolved parent object.
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUserById(id);
}
},
User: {
// Field resolver for computed field
fullName: (parent) => {
return `${parent.firstName} ${parent.lastName}`;
},
// Field resolver for related data
posts: async (parent, args, { dataSources }) => {
// parent.id available from parent User object
return dataSources.postAPI.getPostsByAuthor(parent.id);
},
// Field resolver with arguments
friends: async (parent, { limit }, { dataSources }) => {
return dataSources.userAPI.getFriends(parent.id, limit);
},
// Async computed field
postCount: async (parent, _, { dataSources }) => {
return dataSources.postAPI.countByAuthor(parent.id);
}
},
Post: {
author: async (parent, _, { dataSources }) => {
// parent.authorId from parent Post object
return dataSources.userAPI.getUserById(parent.authorId);
},
comments: async (parent, _, { dataSources }) => {
return dataSources.commentAPI.getByPostId(parent.id);
}
}
};
The context object is shared across all resolvers in a single request. Use it for authentication, data sources, and request-scoped data.
interface Context {
user: User | null;
dataSources: DataSources;
db: Database;
req: Request;
loaders: Loaders;
}
// Context creation function
const createContext = async ({ req }): Promise<Context> => {
// Extract and verify authentication token
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
// Initialize data sources
const dataSources = {
userAPI: new UserAPI(),
postAPI: new PostAPI(),
commentAPI: new CommentAPI()
};
// Initialize DataLoaders
const loaders = {
userLoader: new DataLoader(ids => batchGetUsers(ids)),
postLoader: new DataLoader(ids => batchGetPosts(ids))
};
return {
user,
dataSources,
db: database,
req,
loaders
};
};
// Using context in resolvers
const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) {
throw new Error('Not authenticated');
}
return user;
},
post: async (_, { id }, { loaders }) => {
return loaders.postLoader.load(id);
}
}
};
Resolvers execute in a chain where parent resolvers complete before child resolvers begin. Understanding execution order is crucial for optimization.
const resolvers = {
Query: {
// Step 1: Root resolver executes
user: async (_, { id }, { db }) => {
console.log('1. Fetching user');
return db.users.findById(id);
}
},
User: {
// Step 2: Field resolvers execute with parent data
posts: async (parent, _, { db }) => {
console.log('2. Fetching posts for user', parent.id);
return db.posts.findByAuthor(parent.id);
},
profile: async (parent, _, { db }) => {
console.log('2. Fetching profile for user', parent.id);
return db.profiles.findByUserId(parent.id);
}
},
Post: {
// Step 3: Nested field resolvers execute
comments: async (parent, _, { db }) => {
console.log('3. Fetching comments for post', parent.id);
return db.comments.findByPostId(parent.id);
}
}
};
// Query execution order:
// query {
// user(id: "1") { # 1. User resolver
// posts { # 2. Posts resolver
// comments { # 3. Comments resolver
// text
// }
// }
// profile { # 2. Profile resolver (parallel)
// bio
// }
// }
// }
DataLoader solves the N+1 problem by batching multiple individual loads into a single batch request and caching results.
import DataLoader from 'dataloader';
// Batch function receives array of keys
// Must return array of results in same order
const batchGetUsers = async (userIds: string[]) => {
console.log('Batch loading users:', userIds);
// Single database query for all IDs
const users = await db.users.findByIds(userIds);
// Create map for O(1) lookup
const userMap = new Map(users.map(u => [u.id, u]));
// Return users in same order as input IDs
return userIds.map(id => userMap.get(id) || null);
};
// Create loader in context
const userLoader = new DataLoader(batchGetUsers, {
// Optional configuration
cache: true, // Cache results (default: true)
maxBatchSize: 100, // Maximum batch size
batchScheduleFn: cb => setTimeout(cb, 10) // Custom scheduling
});
const resolvers = {
Post: {
author: async (parent, _, { loaders }) => {
// These calls are automatically batched
return loaders.userLoader.load(parent.authorId);
}
},
Comment: {
author: async (parent, _, { loaders }) => {
// Added to same batch as Post.author
return loaders.userLoader.load(parent.authorId);
}
}
};
// Example: Without DataLoader (N+1 problem)
// Query for 10 posts = 1 query for posts + 10 queries for authors
//
// With DataLoader:
// Query for 10 posts = 1 query for posts + 1 batched query for all
// authors
// Composite key loader
interface CompositeKey {
userId: string;
type: string;
}
const batchGetUserData = async (keys: CompositeKey[]) => {
// Group by type for efficient querying
const byType = keys.reduce((acc, key) => {
acc[key.type] = acc[key.type] || [];
acc[key.type].push(key.userId);
return acc;
}, {});
// Fetch data by type
const results = await Promise.all(
Object.entries(byType).map(([type, userIds]) =>
fetchDataByType(type, userIds)
)
);
// Map back to original key order
return keys.map(key =>
results.find(r => r.userId === key.userId && r.type === key.type)
);
};
const dataLoader = new DataLoader(
batchGetUserData,
{
cacheKeyFn: (key: CompositeKey) => `${key.userId}:${key.type}`
}
);
// Prime the cache
await dataLoader.prime({ userId: '1', type: 'profile' }, userData);
// Clear specific key
dataLoader.clear({ userId: '1', type: 'profile' });
// Clear all cache
dataLoader.clearAll();
Proper error handling in resolvers ensures meaningful errors reach the client while protecting sensitive information.
import { GraphQLError } from 'graphql';
import { ApolloServerErrorCode } from '@apollo/server/errors';
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
try {
const user = await dataSources.userAPI.getUserById(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'USER_NOT_FOUND',
http: { status: 404 }
}
});
}
return user;
} catch (error) {
// Log full error for debugging
console.error('Error fetching user:', error);
// Throw safe error to client
if (error instanceof GraphQLError) {
throw error;
}
throw new GraphQLError('Failed to fetch user', {
extensions: {
code: 'INTERNAL_SERVER_ERROR'
}
});
}
}
},
Mutation: {
createPost: async (_, { input }, { user, dataSources }) => {
// Validation errors
if (!input.title || input.title.length < 3) {
throw new GraphQLError('Title must be at least 3 characters', {
extensions: {
code: 'BAD_USER_INPUT',
argumentName: 'title'
}
});
}
// Authentication errors
if (!user) {
throw new GraphQLError('Must be authenticated', {
extensions: {
code: ApolloServerErrorCode.UNAUTHENTICATED
}
});
}
try {
return await dataSources.postAPI.create(input);
} catch (error) {
throw new GraphQLError('Failed to create post', {
extensions: {
code: 'INTERNAL_SERVER_ERROR'
},
originalError: error
});
}
}
}
};
Implement authentication and authorization patterns in resolvers and context.
// Authentication middleware
const requireAuth = (resolver) => {
return (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
return resolver(parent, args, context, info);
};
};
// Authorization middleware
const requireRole = (role: string) => (resolver) => {
return (parent, args, context, info) => {
if (!context.user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
if (!context.user.roles.includes(role)) {
throw new GraphQLError('Insufficient permissions', {
extensions: { code: 'FORBIDDEN' }
});
}
return resolver(parent, args, context, info);
};
};
const resolvers = {
Query: {
me: requireAuth((_, __, { user }) => user),
adminPanel: requireRole('ADMIN')(
async (_, __, { dataSources }) => {
return dataSources.adminAPI.getDashboard();
}
),
// Resource-based authorization
post: async (_, { id }, { user, dataSources }) => {
const post = await dataSources.postAPI.getById(id);
if (!post) {
throw new GraphQLError('Post not found');
}
// Check if user can view this post
if (post.status === 'DRAFT' && post.authorId !== user?.id) {
throw new GraphQLError('Cannot view draft posts', {
extensions: { code: 'FORBIDDEN' }
});
}
return post;
}
},
Mutation: {
updatePost: requireAuth(
async (_, { id, input }, { user, dataSources }) => {
const post = await dataSources.postAPI.getById(id);
// Check ownership
if (post.authorId !== user.id && !user.roles.includes('ADMIN')) {
throw new GraphQLError('Not authorized to update this post', {
extensions: { code: 'FORBIDDEN' }
});
}
return dataSources.postAPI.update(id, input);
}
)
}
};
Implement caching at the resolver level for improved performance.
import { createHash } from 'crypto';
// In-memory cache
const cache = new Map<string, { data: any; expiry: number }>();
const getCacheKey = (prefix: string, args: any): string => {
const hash = createHash('md5')
.update(JSON.stringify(args))
.digest('hex');
return `${prefix}:${hash}`;
};
const cacheResolver = (
resolver,
{ ttl = 300, prefix = 'cache' } = {}
) => {
return async (parent, args, context, info) => {
const key = getCacheKey(prefix, args);
const cached = cache.get(key);
if (cached && cached.expiry > Date.now()) {
console.log('Cache hit:', key);
return cached.data;
}
const result = await resolver(parent, args, context, info);
cache.set(key, {
data: result,
expiry: Date.now() + (ttl * 1000)
});
return result;
};
};
const resolvers = {
Query: {
// Cache for 5 minutes
popularPosts: cacheResolver(
async (_, { limit }, { dataSources }) => {
return dataSources.postAPI.getPopular(limit);
},
{ ttl: 300, prefix: 'popular-posts' }
),
// Redis caching
user: async (_, { id }, { redis, dataSources }) => {
const cacheKey = `user:${id}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Fetch and cache
const user = await dataSources.userAPI.getUserById(id);
await redis.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
}
};
Create reusable middleware patterns for cross-cutting concerns.
// Logging middleware
const logResolver = (resolver) => {
return async (parent, args, context, info) => {
const start = Date.now();
const fieldName = info.fieldName;
try {
const result = await resolver(parent, args, context, info);
const duration = Date.now() - start;
console.log(`${fieldName} resolved in ${duration}ms`);
return result;
} catch (error) {
console.error(`${fieldName} failed:`, error);
throw error;
}
};
};
// Timing middleware
const timeResolver = (resolver) => {
return async (parent, args, context, info) => {
const start = performance.now();
const result = await resolver(parent, args, context, info);
const duration = performance.now() - start;
// Add timing to extensions
info.operation.extensions = info.operation.extensions || {};
info.operation.extensions.timing =
info.operation.extensions.timing || {};
info.operation.extensions.timing[info.fieldName] = duration;
return result;
};
};
// Compose middleware
const compose = (...middlewares) => (resolver) => {
return middlewares.reduceRight(
(acc, middleware) => middleware(acc),
resolver
);
};
const resolvers = {
Query: {
user: compose(
logResolver,
timeResolver,
requireAuth
)(async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUserById(id);
})
}
};
Write comprehensive tests for resolvers using mocked context and data sources.
import { describe, it, expect, vi } from 'vitest';
describe('User Resolvers', () => {
it('should fetch user by id', async () => {
const mockUser = { id: '1', username: 'test' };
const mockContext = {
dataSources: {
userAPI: {
getUserById: vi.fn().mockResolvedValue(mockUser)
}
}
};
const result = await resolvers.Query.user(
null,
{ id: '1' },
mockContext,
{} as any
);
expect(result).toEqual(mockUser);
expect(mockContext.dataSources.userAPI.getUserById)
.toHaveBeenCalledWith('1');
});
it('should throw error when user not found', async () => {
const mockContext = {
dataSources: {
userAPI: {
getUserById: vi.fn().mockResolvedValue(null)
}
}
};
await expect(
resolvers.Query.user(null, { id: '999' }, mockContext, {} as any)
).rejects.toThrow('User not found');
});
it('should require authentication', async () => {
const mockContext = {
user: null,
dataSources: {}
};
await expect(
resolvers.Query.me(null, {}, mockContext, {} as any)
).rejects.toThrow('Not authenticated');
});
it('should use DataLoader for batching', async () => {
const mockUsers = [
{ id: '1', username: 'user1' },
{ id: '2', username: 'user2' }
];
const batchFn = vi.fn().mockResolvedValue(mockUsers);
const loader = new DataLoader(batchFn);
const mockContext = {
loaders: { userLoader: loader }
};
// Make multiple calls
const [user1, user2] = await Promise.all([
resolvers.Post.author(
{ authorId: '1' },
{},
mockContext,
{} as any
),
resolvers.Post.author(
{ authorId: '2' },
{},
mockContext,
{} as any
)
]);
expect(user1).toEqual(mockUsers[0]);
expect(user2).toEqual(mockUsers[1]);
expect(batchFn).toHaveBeenCalledTimes(1);
expect(batchFn).toHaveBeenCalledWith(['1', '2']);
});
});
Use GraphQL resolver skills when: