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:
- "You can think of each field in a GraphQL query as a function or method of the previous type, which returns the next type."
- "Each field on each type is backed by a resolver function that is written by the GraphQL server developer."
- "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/)