So far, all we've talked about were abstract concepts, such as the schema defining GraphQL constructs and client queries with fields that the response should contain. But how the backend goes from receiving the query and producing the response? Where does the data comes from? This is what the resolvers are for.
For example, the schema might define a user query that returns a User
object:
# schema
type User {
username: String!
email: String
bio: String
}
type Query {
user(username: String!): User
}
Then the client sends a request with a query for a user:
# query
query MyQuery {
user(username: "user1") {
username
bio
}
}
What database query to send to retrieve this user object?
Of course, this is highly dependent on the architecture. Users might be stored in an SQL database, so fetching the one with the given username needs an SQL statement. Or they might be in DynamoDB, a common choice in AWS, and that needs a signed HTTPS request. Or a user directory, or an external system.
Resolvers define the mechanism that connect to the data sources behind the GraphQL API, such as databases, and provide the values for the queries. In practice, most of the time spent working on a GraphQL backend is spent writing resolvers.
How to write resolvers, such as the language they are written in, is highly dependent on the specific GraphQL server you are using. For example, GraphQL.js uses Javascript, and AWS AppSync uses Velocity templates (VTL) as the primary way.
In this chapter, we'll focus on the general concepts of how resolvers work that are the same for all implementations. Then we'll take a detailed look into how AWS AppSync works in the second part of the book.
A resolver provide a value for a field in the response. Fields, as we've covered in the Fields chapter, are both the properties of types (the username
of a User
type) and the queries or mutations (the user
query).
Resolvers provide values for fields.
Let's first consider the top-most part of a client query! This is the field of the Query
type and that is the entry-point to the object graph.
Let's see it through an example!
The schema defines a query that returns a String:
# schema
type Query {
test: String
}
Then a client sends a request to fetch this field:
# query
query MyQuery {
test
}
When this client query reaches the GraphQL server, it looks at the top-level fields. Here, the test
field of the Query
type is the topmost one, so it will resolve that first.
That means a resolver is called, it gets some information about the query (such as the arguments), and provides a text response. What exactly that resolver does is up to the implementation, the only important point is that it must adhere to the type system: since the schema defines a String
result, the resolver must return a String.
When a top-level field (a query or a mutation) returns a complex type, GraphQL needs to resolve the fields of the inner type too. For example, a query might return a User
that has its own fields:
type User {
username: String!
email: String
bio: String
}
type Query {
user(username: String!): User
}
A query can define what inner fields it needs:
# query
query MyQuery {
user(username: "user1") {
username
bio
email
}
}
Here, the first field to resolve is the Query.user
. Its resolver runs, it goes to the database or wherever the user is stored then return this object:
{
"username": "user1",
"bio": "Hi there!"
}
Then GraphQL needs to resolve the User.username
field, so it runs the resolver for that. This resolver gets the source object, which is what the parent resolver returned, in this case the JSON object with the username
and the bio
fields. Since the username
is already in this object, it does not need to contact a database or a separate system but return that value. Then the same process happens with the User.bio
field.
Most servers implement a default resolver that simply returns the appropriate field from the source object. These are called trivial resolvers and they reduce the code you need to write. You can always overwrite these trivial resolvers with something custom.
The third field is the User.email
. In our example, the database does not contain the email address, but there is a separate user directory that does, such as a Cognito User Pool. This means the resolver for this field needs to contact this external system and retrieve the email address for a user. This demonstrates the flexibility of this field-based architecture: even different fields for a type can come from different services.
When the email
field is also resolved, the GraphQL backend now has everything it needs to send back the response.
Nested resolvers get the source object, which is the result of the parent resolver.
All resolver functions, no matter what language it is implemented, get at least these arguments:
The query we used above:
# query
query MyQuery {
user(username: "user1") {
username
bio
email
}
}
In the case of the Query.user
resolver, these are:
username: "user1"
argumentSince the resolver gets the username
argument, it knows what object to query in the database. Also, as the metadata may contain login information, the resolver can decide whether or not to allow this operation. We'll cover this in detail in the Authentication chapter.
Then when the User.email
resolver runs, it gets these parameters:
User.email
field)Since the source object contains the username
field, so the resolver knows what user to get from the directory.
The parent resolver is free to return anything, as long as it is enough for the nested resolvers to provide values.
Resolvers run for every field in the response. This means when a type is a list of a type, all its nested types will be resolved for each item.
For example, this query returns a list of users:
# schema
type User {
username: String!
email: String
bio: String
}
type Query {
allUsers: [User]
}
Then the client query can define what fields are needed for each object in the list:
# query
query MyQuery {
allUsers {
username
bio
}
}
The GraphQL backend resolves the top-level field (Query.allUsers
) that returns a list, then it resolves the username
and the bio
for every item in that list. In that case, the nested resolvers get one item of the list as the source object.