Skip to main content
Technology & EngineeringGraphql218 lines

Resolvers

Resolver patterns, data loading strategies, and the N+1 problem in GraphQL

Quick Summary15 lines
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 lines
Paste into your CLAUDE.md or agent config

Resolvers — 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 somePromise in 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 info argument — 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 inspecting info.

Best Practices

  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 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 info argument — for advanced optimizations, info tells you which fields were requested, enabling selective database column fetching or join optimization.
  • Circular dependency loopsUser.posts -> Post.author -> User.posts is 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

Get CLI access →