ð graphql-resolvers
Use when implementing GraphQL resolvers with resolver functions, context management, DataLoader batching, error handling, authentication, and testing strategies.
Overview
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.
Resolver Function Signature
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
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);
}
}
};
Context Object Patterns
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);
}
}
};
Resolver Chains and Execution
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 Pattern for Batching
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
Advanced DataLoader Patterns
// 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();
Async Error Handling
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
});
}
}
}
};
Authentication and Authorization
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);
}
)
}
};
Caching Strategies
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;
}
}
};
Resolver Middleware and Plugins
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);
})
}
};
Testing Resolvers
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']);
});
});
Best Practices
- Keep resolvers thin: Delegate business logic to service layer, use resolvers only for data fetching and transformation
- Use DataLoader: Implement DataLoader for any resolver that fetches related data to avoid N+1 queries
- Leverage context: Store shared resources (database, auth, data sources) in context for all resolvers
- Handle errors gracefully: Catch errors and throw meaningful GraphQLError instances with appropriate codes
- Implement proper auth: Check authentication and authorization in resolvers or middleware consistently
- Cache strategically: Cache expensive operations at resolver level using in-memory or distributed cache
- Use typed resolvers: Define TypeScript types for resolver functions to catch errors at compile time
- Test thoroughly: Write unit tests for resolvers with mocked dependencies and edge cases
- Avoid blocking operations: Use async/await and parallel execution where possible to prevent blocking
- Monitor performance: Log resolver execution time and identify slow resolvers for optimization
Common Pitfalls
- N+1 queries: Fetching related data in loops without batching, causing excessive database queries
- Blocking operations: Using synchronous operations in resolvers that block the event loop
- Memory leaks: Storing data in closures or module scope that grows unbounded
- Inconsistent error handling: Throwing raw errors without proper GraphQLError wrapping and codes
- Over-fetching in resolvers: Fetching entire objects when only specific fields are needed
- Context mutation: Modifying context object during resolver execution, causing side effects
- Missing authentication checks: Forgetting to verify auth in sensitive resolvers
- Improper DataLoader usage: Creating new DataLoader instances per resolver instead of per request
- Circular resolver chains: Creating resolver dependencies that cause infinite loops
- Not using info parameter: Ignoring the info parameter that contains requested fields for optimization
When to Use This Skill
Use GraphQL resolver skills when:
- Implementing a new GraphQL server
- Optimizing existing resolver performance
- Debugging N+1 query problems
- Adding authentication and authorization
- Implementing data batching and caching
- Writing resolver unit tests
- Refactoring resolvers for better maintainability
- Adding logging and monitoring to resolvers
- Implementing custom middleware or plugins
- Migrating from REST API to GraphQL
Resources
- GraphQL Resolvers Documentation - Official execution and resolver guide
- DataLoader GitHub - Official DataLoader library and documentation
- Apollo Server Resolvers Resolver patterns and examples
- GraphQL Error Handling Error handling best practices
- Testing GraphQL Resolvers Testing strategies