TechLead
Lesson 4 of 20
7 min read
GraphQL

Resolvers Deep Dive

Learn how resolvers work, the resolver chain, context objects, and how to connect GraphQL to any data source

What Are Resolvers?

Resolvers are the functions that actually fetch the data for each field in your GraphQL schema. Every field on every type has a resolver function. When the server receives a query, it calls the resolver for each field in the query, starting from the root and working down through nested fields.

A resolver function receives four arguments: parent (the result of the parent resolver), args (the arguments passed to the field), context (shared state like database connections and auth info), and info (metadata about the query execution).

// Resolver function signature
type ResolverFn = (
  parent: any,       // Result from the parent resolver
  args: any,         // Arguments passed to the field
  context: Context,  // Shared context (DB, auth, etc.)
  info: GraphQLResolveInfo // Query execution metadata
) => any;

Building Resolvers Step by Step

Let us build a complete set of resolvers for a blog API. First, the schema:

type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
  post(id: ID!): Post
  posts(filter: PostFilterInput): [Post!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  postsCount: Int!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  comments: [Comment!]!
  commentsCount: Int!
  createdAt: String!
}

type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
}

The Context Object

The context is created fresh for every request and shared across all resolvers. It typically contains database connections, authenticated user information, and data loaders:

import { PrismaClient } from '@prisma/client';
import DataLoader from 'dataloader';

interface Context {
  prisma: PrismaClient;
  currentUser: User | null;
  loaders: {
    userLoader: DataLoader<string, User>;
    postsByAuthorLoader: DataLoader<string, Post[]>;
  };
}

// Create context for each request
function createContext({ req }: { req: Request }): Context {
  const prisma = new PrismaClient();
  const token = req.headers.get('authorization')?.replace('Bearer ', '');
  const currentUser = token ? verifyToken(token) : null;

  return {
    prisma,
    currentUser,
    loaders: {
      userLoader: new DataLoader(async (ids: readonly string[]) => {
        const users = await prisma.user.findMany({
          where: { id: { in: [...ids] } },
        });
        return ids.map(id => users.find(u => u.id === id)!);
      }),
      postsByAuthorLoader: new DataLoader(async (authorIds: readonly string[]) => {
        const posts = await prisma.post.findMany({
          where: { authorId: { in: [...authorIds] } },
        });
        return authorIds.map(id => posts.filter(p => p.authorId === id));
      }),
    },
  };
}

Query Resolvers

const resolvers = {
  Query: {
    // Fetch a single user by ID
    user: async (_parent: unknown, args: { id: string }, ctx: Context) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: args.id },
      });
      return user; // Returns null if not found, matching nullable return type
    },

    // Fetch a list of users with pagination
    users: async (
      _parent: unknown,
      args: { limit: number; offset: number },
      ctx: Context
    ) => {
      return ctx.prisma.user.findMany({
        take: args.limit,
        skip: args.offset,
        orderBy: { createdAt: 'desc' },
      });
    },

    // Fetch a single post by ID
    post: async (_parent: unknown, args: { id: string }, ctx: Context) => {
      return ctx.prisma.post.findUnique({
        where: { id: args.id },
      });
    },

    // Fetch posts with optional filtering
    posts: async (
      _parent: unknown,
      args: { filter?: { status?: string; authorId?: string; search?: string } },
      ctx: Context
    ) => {
      const where: any = {};
      if (args.filter?.status) where.status = args.filter.status;
      if (args.filter?.authorId) where.authorId = args.filter.authorId;
      if (args.filter?.search) {
        where.OR = [
          { title: { contains: args.filter.search, mode: 'insensitive' } },
          { body: { contains: args.filter.search, mode: 'insensitive' } },
        ];
      }
      return ctx.prisma.post.findMany({ where, orderBy: { createdAt: 'desc' } });
    },
  },
};

Field Resolvers (The Resolver Chain)

When GraphQL resolves nested fields, it passes the parent object's result to child resolvers. This is how the resolver chain works — each level receives the resolved data from the level above:

const resolvers = {
  // ... Query resolvers above

  // Field resolvers for User type
  User: {
    // parent is the User object returned by the parent resolver
    posts: async (parent: User, _args: unknown, ctx: Context) => {
      // Use DataLoader to batch and cache user post lookups
      return ctx.loaders.postsByAuthorLoader.load(parent.id);
    },

    postsCount: async (parent: User, _args: unknown, ctx: Context) => {
      return ctx.prisma.post.count({
        where: { authorId: parent.id },
      });
    },
  },

  // Field resolvers for Post type
  Post: {
    // Resolve the author field — parent is the Post object
    author: async (parent: Post, _args: unknown, ctx: Context) => {
      // Use DataLoader to avoid N+1 queries
      return ctx.loaders.userLoader.load(parent.authorId);
    },

    comments: async (parent: Post, _args: unknown, ctx: Context) => {
      return ctx.prisma.comment.findMany({
        where: { postId: parent.id },
        orderBy: { createdAt: 'asc' },
      });
    },

    commentsCount: async (parent: Post, _args: unknown, ctx: Context) => {
      return ctx.prisma.comment.count({
        where: { postId: parent.id },
      });
    },
  },

  // Field resolvers for Comment type
  Comment: {
    author: async (parent: Comment, _args: unknown, ctx: Context) => {
      return ctx.loaders.userLoader.load(parent.authorId);
    },

    post: async (parent: Comment, _args: unknown, ctx: Context) => {
      return ctx.prisma.post.findUnique({
        where: { id: parent.postId },
      });
    },
  },
};

How the Resolver Chain Works

When this query executes:

  1. Query.post runs first, returning a Post object from the database
  2. The Post object becomes the parent argument for Post.author, Post.comments, etc.
  3. Each Comment becomes the parent for Comment.author
  4. Scalar fields (title, text, etc.) resolve automatically from the parent object — no resolver needed

Mutation Resolvers

const resolvers = {
  // ... Query and type resolvers

  Mutation: {
    createUser: async (
      _parent: unknown,
      args: { input: { name: string; email: string } },
      ctx: Context
    ) => {
      // Validate input
      if (!args.input.email.includes('@')) {
        throw new GraphQLError('Invalid email address', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      return ctx.prisma.user.create({
        data: {
          name: args.input.name,
          email: args.input.email,
        },
      });
    },

    createPost: async (
      _parent: unknown,
      args: { input: { title: string; body: string; isPublished?: boolean } },
      ctx: Context
    ) => {
      // Check authentication
      if (!ctx.currentUser) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      return ctx.prisma.post.create({
        data: {
          title: args.input.title,
          body: args.input.body,
          isPublished: args.input.isPublished ?? false,
          authorId: ctx.currentUser.id,
        },
      });
    },

    updatePost: async (
      _parent: unknown,
      args: { id: string; input: { title?: string; body?: string; isPublished?: boolean } },
      ctx: Context
    ) => {
      if (!ctx.currentUser) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      // Check ownership
      const post = await ctx.prisma.post.findUnique({ where: { id: args.id } });
      if (!post || post.authorId !== ctx.currentUser.id) {
        throw new GraphQLError('Post not found or unauthorized', {
          extensions: { code: 'FORBIDDEN' },
        });
      }

      return ctx.prisma.post.update({
        where: { id: args.id },
        data: args.input,
      });
    },

    deletePost: async (
      _parent: unknown,
      args: { id: string },
      ctx: Context
    ) => {
      if (!ctx.currentUser) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }

      const post = await ctx.prisma.post.findUnique({ where: { id: args.id } });
      if (!post || post.authorId !== ctx.currentUser.id) {
        throw new GraphQLError('Post not found or unauthorized', {
          extensions: { code: 'FORBIDDEN' },
        });
      }

      await ctx.prisma.post.delete({ where: { id: args.id } });
      return true;
    },
  },
};

Resolver Best Practices

  • Keep resolvers thin: Move business logic into service layers, not resolvers
  • Use DataLoader: Prevent N+1 queries by batching and caching database calls
  • Default resolvers work: If a field name matches a property on the parent object, GraphQL resolves it automatically
  • Always validate in mutations: Check auth, permissions, and input before writing data
  • Throw GraphQLError: Use structured errors with extension codes for proper client handling

Continue Learning