Understanding GraphQL Resolvers

In this latest blog from the developers on our Product Traction team, they share their expertise with GraphQL resolvers including how and when to use them.

What Are GraphQL Resolvers?

A GraphQL resolver is a function that returns the expected response for a GraphQL query. Resolvers act as the implementation layer of a GraphQL schema, determining how requested data is fetched or computed. While it’s common to define resolvers for each field in a GraphQL schema’s Query type, did you know that resolvers can be defined for any field on any type in the schema? In this post, we’ll explore how field-specific resolvers work and when to use them for optimal performance.

What is the GraphQL Resolver Chain?

Here are three key principles from the GraphQL execution documentation that help in understanding resolver chains:

  1. "You can think of each field in a GraphQL query as a function or method of the previous type, which returns the next type."
  2. "Each field on each type is backed by a resolver function that is written by the GraphQL server developer."
  3. "When a field is executed, the corresponding resolver is called to produce the next value."

These statements outline the logical flow of a GraphQL query, from resolver execution to the final response.

Let’s consider the following type definitions:

type User {
  id: ID!
  name: String
}

type Query {
  user(id: ID!): User
}

And the corresponding query:

query {
  user(id: 123) {
    name
  }
}

This query requests the name field from the User type. Based on the principles above, we can break down how resolvers function:

1. Each field in a GraphQL query acts like a function
  • The root query field functions as an entry point, returning the Query type.
  • The user field on the Query type returns a User object.
  • The name field on the User type returns a String.
2. Each field is backed by a resolver function
const resolvers = {
  Query: {
    user: (parent, args, context) => context.db.fetchUser(args.id), // Returns { id: 123, name: "John Doe" }
  },
};

The parent argument represents the return value of the previous resolver in the chain. This allows nested resolvers to access data from higher levels.

3. When a field is executed, the resolver function is called
// Executing the `query` field on the root type

query: () => resolvers.Query
  // Executing `user` on `Query`
  user(id: 123) => resolvers.Query.user(parent, {id: 123}, context) =>
    context.db.fetchUser(123) => { id: 123, name: "John Doe" }
    // Executing `name` on `User`
    name: (user) => user.name => "John Doe"

The final response:

{
  "data": {
    "user": {
      "name": "John Doe"
    }
  }
}

Resolvers bridge the schema types and actual data, ensuring the requested query resolves to the expected response.

Defining Resolvers for Additional Types

Resolvers are not limited to only fields on the Query type; they can be defined for any type in the schema. Let’s expand our example by adding a country field to the User type:

type User {
  id: ID!
  name: String
  country: String
}

Assume that our database stores the country as an ISO country code, but we want to return the full country name to the client. We can use a mapping for this:

const CountryCodeToNameMap = new Map([
  ['CA', 'Canada'],
  // ... other country codes
]);

A naive approach is to add this mapping within the user resolver:

const resolvers = {
  Query: {
    user: (parent, args, context) => {
      const dbUser = context.db.fetchUser(args.id);
      return {
        ...dbUser,
        country: CountryCodeToNameMap.get(dbUser.country),
      };
    },
  },
};

However, if we introduce another query that returns multiple users:

type Query {
  user(id: ID!): User
  users: [User]
}

Then we would have to repeat the country mapping logic:

const resolvers = {
  Query: {
    user: (parent, args, context) => {
      const dbUser = context.db.fetchUser(args.id);
      return {
        ...dbUser,
        country: CountryCodeToNameMap.get(dbUser.country),
      };
    },
    users: (parent, args, context) => {
      return context.db.fetchAllUsers().map((dbUser) => ({
        ...dbUser,
        country: CountryCodeToNameMap.get(dbUser.country),
      }));
    },
  },
};

A better approach is to define a resolver for the country field on the User type:

const resolvers = {
  Query: {
    user: (parent, args, context) => context.db.fetchUser(args.id),
    users: (parent, args, context) => context.db.fetchAllUsers(),
  },
  User: {
    country: (parent) => CountryCodeToNameMap.get(parent.country),
  },
};

Now, whenever the country field is requested on a User, it is resolved separately.

For the query:

query {
  user(id: 123) {
    name
    country
  }
}

The resolver chain will now include:

// Resolving `User.country`
country: (dbUser) => resolvers.User.country(dbUser) => CountryCodeToNameMap.get(dbUser.country) => "Canada"

Final response:

{
  "data": {
    "user": {
      "name": "John Doe",
      "country": "Canada"
    }
  }
}

This makes the code cleaner, more maintainable, and avoids redundant logic.

Optimizing Expensive Operations 

Field resolvers also help optimize expensive operations, ensuring certain computations only run when necessary. For example:

Fetching External Data

type User {
  id: ID!
  name: String
  externalData: JSON
}

const resolvers = {
  User: {
    // ...other User field resolvers
    externalData: (parent, args, context) => context.apiManager.get(parent.id),
  },
  Query: {
    // ...query field resolvers
  },
};

Expensive Database Queries

type User {
  id: ID!
  name: String
  reports: [Report]
}

const resolvers = {
  User: {
    // ...other User field resolvers
    reports: async (parent, args, context) => {
      return [
        await context.db.sales.aggregateReports(),
        await context.db.orders.aggregateReports(),
        await context.db.aggregate({ expenses: 'CAD' }),
      ];
    },
  },
  Query: {
    // ...query field resolvers
  },
};

Conclusion

Understanding the resolver chain and strategically placing resolvers at the right level helps maintain a clean, efficient, and optimized GraphQL server. By leveraging field resolvers, we can help ensure:

  • minimal redundant logic
  • unnecessary computation
  • improving overall performance
  • your GraphQL API stays modular and maintainable

Further Reading

[Learn GraphQL](https://graphql.org/learn/)

[GraphQL Best Practices](https://graphql.org/learn/best-practices/)


The Thin Air Labs Product Traction team provides strategic product, design and development services for companies of all sizes, with a specific focus on team extensions where they seamlessly integrate into an existing team. Whether they are deployed as a team extension or as an independent unit, they always work with a Founder-First Mindset to ensure their clients receive the support they need.

To learn more about our Product Traction service, go here.

Build what's next with us