Resolvers
Resolver patterns, data loading strategies, and the N+1 problem in GraphQL
You are an expert in GraphQL resolver architecture, helping developers write efficient, maintainable resolvers that handle data fetching, batching, and error handling correctly. ## Key Points 1. **Create DataLoaders per request** — never share DataLoader instances across requests; they cache within a request lifecycle and sharing would leak data between users. 2. **Keep resolvers thin** — resolvers should delegate to data sources or services, not contain business logic directly. 3. **Use context for cross-cutting concerns** — authentication state, DataLoaders, and database connections belong in context, not imported as globals. 4. **Colocate resolver logic with the type** — define `User.posts` in the Posts module, not the Users module, so each domain owns its resolver logic. 5. **Return promises, don't await unnecessarily** — returning a promise directly is more efficient than `await`ing just to return the value. - **N+1 queries** — the most pervasive GraphQL performance issue. Every nested list or relationship field that triggers a database query per item needs DataLoader batching. - **Shared DataLoader instances** — caching user A's data and returning it to user B is a security vulnerability. Always create loaders per request. - **Heavy computation in resolvers** — resolvers run on every request. Expensive operations should be precomputed, cached, or offloaded to background jobs. - **Ignoring the `info` argument** — for advanced optimizations, `info` tells you which fields were requested, enabling selective database column fetching or join optimization.
skilldb get graphql-skills/ResolversFull skill: 218 linesResolvers — GraphQL
You are an expert in GraphQL resolver architecture, helping developers write efficient, maintainable resolvers that handle data fetching, batching, and error handling correctly.
Overview
Resolvers are the functions that populate the data for each field in your schema. Every field in a GraphQL schema has a corresponding resolver, whether explicit or via a default trivial resolver. Resolver design directly determines the performance, maintainability, and correctness of a GraphQL API.
Core Concepts
Resolver Signature
Every resolver receives four arguments:
type Resolver = (
parent: any, // Result from the parent resolver
args: object, // Arguments passed to this field
context: Context, // Shared per-request state (auth, dataloaders, db)
info: GraphQLResolveInfo // AST and schema metadata for the query
) => any | Promise<any>;
Resolver Map Structure
Resolvers are organized by type name, then field name:
const resolvers = {
Query: {
user: (_, { id }, { dataSources }) => dataSources.users.getById(id),
users: (_, { filter, first, after }, { dataSources }) =>
dataSources.users.list({ filter, first, after }),
},
User: {
posts: (user, { first, after }, { dataSources }) =>
dataSources.posts.getByAuthorId(user.id, { first, after }),
fullName: (user) => `${user.firstName} ${user.lastName}`,
},
Mutation: {
createPost: (_, { input }, { dataSources, currentUser }) =>
dataSources.posts.create({ ...input, authorId: currentUser.id }),
},
};
Default Resolvers
GraphQL engines provide a default resolver that returns parent[fieldName]. You only need explicit resolvers when the field name differs from the property name, requires transformation, or fetches from another source.
Implementation Patterns
DataLoader for Batching and Caching
The N+1 problem is the most common performance issue in GraphQL. DataLoader solves it by batching and deduplicating requests within a single tick:
import DataLoader from "dataloader";
// Create per-request DataLoader instances
function createLoaders(db: Database) {
return {
userById: new DataLoader<string, User>(async (ids) => {
const users = await db.users.findByIds([...ids]);
const userMap = new Map(users.map((u) => [u.id, u]));
return ids.map((id) => userMap.get(id) ?? new Error(`User ${id} not found`));
}),
postsByAuthorId: new DataLoader<string, Post[]>(async (authorIds) => {
const posts = await db.posts.findByAuthorIds([...authorIds]);
const grouped = new Map<string, Post[]>();
for (const post of posts) {
const list = grouped.get(post.authorId) ?? [];
list.push(post);
grouped.set(post.authorId, list);
}
return authorIds.map((id) => grouped.get(id) ?? []);
}),
};
}
// Use in resolvers
const resolvers = {
Post: {
author: (post, _, { loaders }) => loaders.userById.load(post.authorId),
},
User: {
posts: (user, _, { loaders }) => loaders.postsByAuthorId.load(user.id),
},
};
Data Source Classes
Encapsulate data access logic in data source classes for testability:
class PostsDataSource {
constructor(private db: Database, private loaders: ReturnType<typeof createLoaders>) {}
async getById(id: string): Promise<Post | null> {
return this.loaders.postById.load(id);
}
async create(input: CreatePostInput & { authorId: string }): Promise<Post> {
const post = await this.db.posts.insert(input);
this.loaders.postById.prime(post.id, post); // Prime the cache
return post;
}
async update(id: string, input: UpdatePostInput): Promise<Post> {
const post = await this.db.posts.update(id, input);
this.loaders.postById.clear(id).prime(id, post); // Clear then reprime
return post;
}
}
Field-Level Authorization
Apply authorization checks within resolvers or as resolver middleware:
const resolvers = {
User: {
email: (user, _, { currentUser }) => {
if (currentUser.id !== user.id && !currentUser.isAdmin) {
return null; // or throw new ForbiddenError(...)
}
return user.email;
},
},
};
// Or use a directive / middleware pattern
function authorized(role: string, resolver: Resolver): Resolver {
return (parent, args, context, info) => {
if (!context.currentUser?.roles.includes(role)) {
throw new ForbiddenError("Insufficient permissions");
}
return resolver(parent, args, context, info);
};
}
Error Handling in Resolvers
Return structured errors from mutations rather than throwing:
const resolvers = {
Mutation: {
createPost: async (_, { input }, { dataSources, currentUser }) => {
const errors: UserError[] = [];
if (!input.title.trim()) {
errors.push({ field: ["title"], message: "Title is required", code: "REQUIRED" });
}
if (input.title.length > 200) {
errors.push({ field: ["title"], message: "Title too long", code: "TOO_LONG" });
}
if (errors.length > 0) {
return { post: null, errors };
}
const post = await dataSources.posts.create({
...input,
authorId: currentUser.id,
});
return { post, errors: [] };
},
},
};
Core Philosophy
Resolvers are the execution engine of your GraphQL API, and their design determines whether the API is fast, maintainable, and correct. The cardinal rule is that resolvers should be thin — they are coordination points, not business logic containers. A resolver's job is to accept the parent value, extract the relevant arguments, call into a data source or service, and return the result. When resolvers grow beyond a few lines, business logic has leaked into the wrong layer.
The N+1 problem is not a bug to be fixed once; it is a structural consequence of GraphQL's nested execution model. Every relationship field that triggers a separate database query will execute once per parent row, turning a simple-looking query into hundreds of SQL statements. DataLoader is the standard solution: it batches all loads within a single tick into one query and deduplicates identical requests. DataLoader should be the default for every relationship resolver, not an optimization applied after performance complaints.
The resolver tree is a natural decomposition of your data graph. Each type owns its own field resolvers, and each field resolver is responsible for exactly one piece of data. This means the Post.author resolver lives with the Posts module (not the Users module), because it represents how a post resolves its author — a concern of the post, not the user. This ownership model scales cleanly as the schema grows and prevents circular dependency between domain modules.
Anti-Patterns
-
Business logic in resolvers — validation, authorization, transformation, and persistence logic embedded directly in resolver functions creates an untestable, unreusable monolith. Extract this logic into service classes or data sources that can be tested independently.
-
Skipping DataLoader for "small" relationships — the N+1 problem compounds silently. A relationship that loads 10 rows today may load 10,000 tomorrow. DataLoader adds negligible overhead and prevents an entire class of performance regressions.
-
Sharing DataLoader instances across requests — DataLoader caches results for the lifetime of the instance. Sharing one across requests means user A's data leaks to user B. Always create fresh DataLoader instances per request in the context function.
-
Awaiting unnecessarily in resolvers — writing
return await somePromisein a resolver that simply returns the value adds a microtask hop for no benefit. Return the promise directly (return somePromise) unless you need to catch errors or do post-processing. -
Ignoring the
infoargument — the fourth resolver argument contains the parsed AST of the query, revealing exactly which fields the client requested. Advanced optimizations like selective column fetching, join planning, and look-ahead loading depend on inspectinginfo.
Best Practices
- Create DataLoaders per request — never share DataLoader instances across requests; they cache within a request lifecycle and sharing would leak data between users.
- Keep resolvers thin — resolvers should delegate to data sources or services, not contain business logic directly.
- Use context for cross-cutting concerns — authentication state, DataLoaders, and database connections belong in context, not imported as globals.
- Colocate resolver logic with the type — define
User.postsin the Posts module, not the Users module, so each domain owns its resolver logic. - Return promises, don't await unnecessarily — returning a promise directly is more efficient than
awaiting just to return the value.
Common Pitfalls
- N+1 queries — the most pervasive GraphQL performance issue. Every nested list or relationship field that triggers a database query per item needs DataLoader batching.
- Shared DataLoader instances — caching user A's data and returning it to user B is a security vulnerability. Always create loaders per request.
- Heavy computation in resolvers — resolvers run on every request. Expensive operations should be precomputed, cached, or offloaded to background jobs.
- Ignoring the
infoargument — for advanced optimizations,infotells you which fields were requested, enabling selective database column fetching or join optimization. - Circular dependency loops —
User.posts -> Post.author -> User.postsis valid in the schema but can cause infinite loops in poorly written resolvers. DataLoader's caching prevents redundant fetches, but query depth limits are still important.
Install this skill directly: skilldb add graphql-skills
Related Skills
Apollo Client
Apollo Client with React for querying, mutating, and caching GraphQL data
Apollo Server
Apollo Server setup, configuration, plugins, and production deployment patterns
Authentication
Authentication and authorization patterns for securing GraphQL APIs
Code Generation
Type-safe GraphQL development with graphql-codegen for TypeScript
Pagination
Cursor-based pagination following the Relay connection specification
Schema Design
Schema design principles for building maintainable, intuitive GraphQL APIs