GraphQL resolvers allow a client to specify what data it wants from a server.
In a previous article, we discussed some foundational elements of GraphQL, such as the schema, object types and their relationships. Today, we’ll be diving deeper into another pivotal component of any GraphQL server—GraphQL resolvers.
If we recall, GraphQL is incredibly powerful in allowing a client to specify exactly what data it desires from a server. However, how does the server know how to actually fetch or derive this data? This is where resolvers come into play.
In a GraphQL schema, the primary entry points for client requests are termed root fields. These are fields on the main Query
, Mutation
and Subscription
types. Whenever a client query arrives, it’s the root fields that initially respond.
For example, consider user
as a root field on the Query
type.
type Query {
user(id: ID!): User
}
type User {
name: String!
email: String!
}
The GraphQL query to fetch user
information would look like the following:
{
user(id: 1) {
name
email
}
}
A GraphQL resolver is essentially a function whose job is to populate the data for a single field in our schema. Whenever a field is executed in a query, the function tied to that field is called which then provides the needed data.
For the user
example we have above, to fetch the name
and email
for a particular user, we’d have a resolver function for the user
field. This resolver would interact with our database or any other data source to retrieve the required information.
const resolvers = {
Query: {
user: (obj, args, context, info) => {
// fetch the user data from the database using the id from args
return database.getUserById(args.id);
},
},
};
A GraphQL resolver always receives four positional arguments.
Query
and Mutation
object types, this argument is often not used and undefined.user
query above, the id
argument would be available in the args
parameter.fieldName
, schema
, rootValue
, etc.It’s worth noting that every field in a GraphQL schema is backed by a resolver. If no resolver is explicitly defined for a field, GraphQL libraries would oftentimes utilize a default resolver. To explain this some more, consider the User
type in our example GraphQL schema.
type User {
name: String!
email: String!
}
If we’ve already retrieved a user
object from our database with properties that match our GraphQL object type, and we don’t specify resolvers for name
and email
, GraphQL will use default resolvers. This means it will look at the parent object (in this case, our user
object from the database) for properties with the same name and return them.
// If our user object from the database is like this:
const userFromDatabase = {
name: "John Doe",
email: "john@example.com",
};
// GraphQL will resolve the fields as if we had these resolvers:
const resolvers = {
User: {
name: (user) => user.name,
email: (user) => user.email,
},
};
However, in cases where the fields in our GraphQL schema and the underlying data source aren’t directly mapped or when certain fields require additional computation, custom resolvers become necessary.
Resolvers have the capability to execute asynchronous operations, like database queries or API calls. In these situations, a resolver can yield a promise which eventually settles to an acceptable return value. As an example, suppose we want to fetch a user’s recent purchase history from an external microservice. In our schema, this could look like:
type User {
name: String!
email: String!
recentPurchases: [Purchase!]!
}
type Purchase {
id: ID!
item: String!
price: Float!
}
To obtain this purchase data, we might need to make an asynchronous API call in our resolver function:
const resolvers = {
User: {
recentPurchases: async (user, args, context) => {
// Assuming we have a function to fetch purchases from an API
const purchases = await api.fetchPurchasesForUser(user.id);
return purchases;
},
},
};
Notice that in this scenario, the resolver function is asynchronous, marked by the async
keyword. The function makes use of the await
keyword to wait for the API call to complete. This asynchronous pattern ensures that while we’re waiting for data, other operations can continue, making efficient use of the server’s resources.
One fascinating application of resolvers is to perform field-level authorization. In a GraphQL schema, we can have fields that should only be accessible to certain users based on their roles or permissions. Instead of handling this authorization logic at the controller or route level (as commonly done in RESTful APIs), GraphQL allows us to encapsulate this logic right within the resolver.
Let’s take our User
type as an example that now has a new privateNote
field:
type User {
name: String!
email: String!
privateNote: String
}
Suppose the privateNote
field should only be accessible by the user viewing their own information. We can implement this in the resolver like so:
const resolvers = {
User: {
privateNote: (user, args, context) => {
// Check if the user is authenticated and is the owner
if (context.currentUser && context.currentUser.id === user.id) {
return user.privateNote;
}
// Return null or throw an error if unauthorized
return null;
},
},
};
In the resolver, we utilize the context
argument to determine the currently authenticated user. Depending on the nature of our application, we might choose to return a default value (like null
), throw an error or even return a masked value if the request is made by a user who isn’t authenticated.
Though the above approach works well, for real-world production applications, it is often best to delegate authorization logic to the business logic layer. This ensures that the resolver remains clean, focused on data retrieval, and adheres to the principle of separation of concerns.
An example of this can perhaps involve using a hypothetical authorize()
middleware function that handles authorization:
const authorize = (resolverFunction) => {
return (user, args, context, info) => {
if (!context.currentUser || context.currentUser.id !== user.id) {
// This could also be a masked value or a custom error
return null;
}
return resolverFunction(user, args, context, info);
};
};
const resolvers = {
User: {
privateNote: authorize((user, args, context) => {
return user.privateNote;
}),
},
};
In the code above, the authorize()
function acts as a higher-order function that wraps our original resolver function. It checks the user’s authorization before proceeding to call the actual resolver function. If the user doesn’t have the required permissions, it short-circuits the process and returns a default value (in this case, null
).
This approach leads to better modularity and maintainability of the codebase. By externalizing logic like authorization into a middleware function, we can more seamlessly update, expand or replace security mechanisms without the need to dig deep into the core business logic or data-fetching routines.
Resolvers play a crucial role in GraphQL’s architecture, bridging the gap between the client’s data requests and the actual data sources. Whether resolver functions are used to fetch data, perform computations or transform responses, resolvers ensure that clients get exactly what they ask for.
Hassan is a senior frontend engineer and has helped build large production applications at-scale at organizations like Doordash, Instacart and Shopify. Hassan is also a published author and course instructor where he’s helped thousands of students learn in-depth frontend engineering skills like React, Vue, TypeScript, and GraphQL.