Lean in to the graph when writing resolvers
Designing great resolver functions in GraphQL is quite unintuitive. Giving consumers the ability to describe their desired response payload means it’s possible for consumers to access fields via surprising paths through the graph, and this is something that can and should influence how you write your resolvers on a field level.
Say you’re building a kanban board app like Trello or Asana. A board consists of many different named columns, which each contain a list of tasks that can be dragged from one column to another. Columns are typically used to indicate that status of a task; for instance, you might have three columns labeled “To Do”, “In Progress”, and “Done” at a software company.
The order of each column inside a board, and the order of each task within a column is entirely user-defined.
We need quite a few mutations to build out our app, but the core query schema is quite simple:
type Board { id: ID! title: String! columns: [BoardColumn!]!} type BoardColumn { id: ID! title: String! tasks: [Task!]!} type Task { id: ID! title: String! description: String! dueAt: DateTime} type Query { board(id: ID!): Board boards(query: String): [Board!]!}
Assuming we use a relational database as our backing store we likely need to store an index
field on our column and task rows, but I’ve decided there’s no need to expose this detail in my GraphQL schema. I’m also choosing not to support pagination, making the assumption that the number of columns per board and the number of tasks per column will be low. This would be a dangerous assumption to make in the real world, but is OK for the purposes of this post.
How might we implement our board
resolver?
Naive approach: Eagerly resolving relations
If you’re coming from a REST or gRPC background, then simply loading all of the data for the query upfront likely feels natural. This is by far the simplest way to implement the resolver, and in some circumstances a naïve approach like this one can make sense.
The implementation could look like the below code snippet if you’re using Pothos as the GraphQL server and Prisma as your ORM:
builder.queryType({ fields: (t) => ({ board: t.field({ type: Board, args: { id: t.arg.id({ required: true }), }, resolve: (parent, args, ctx) => ctx.board.findUnique({ where: { id: args.id }, include: { columns: { orderBy: { index: 'asc' }, include: { tasks: { orderBy: { index: 'asc' }, }, }, }, }, }), }), }),});
We should use a data loader here instead of using the ORM from directly within the resolver. Even simple “by ID” lookups are vulnerable to the N+1 query problem due to aliasing. For simplicity, I’ve omitted the loader.
So far so good. The boards
resolver is similarly straightforward:
builder.queryType({ fields: (t) => ({ // <snip> `board` boards: t.field({ type: [Board], nullable: { list: false, items: false, }, args: { query: t.arg.string(), }, resolve: (parent, args, ctx) => ctx.board.findMany({ where: { title: { search: args.query } }, include: { // copy/paste from the `board` resolver... }, }), }), },});
One thing that stands out about the eager-loading strategy is we wind up with duplicated code. In this very minimal example it’s easy to see how you could extract a shared function, but extracting a function doesn’t tend to scale well.
Say we did extract a fetchBoards
function. Then imagine we want a new task(id: ID!): Task
query and that we want to add a new isAssignedToMe: Boolean!
field to the Task
type.
Adding logic inside fetchBoards
to add the isAssignedToMe
field will look pretty nasty, as you need to loop over lists of lists of objects in order to get access to the underlying Task
instances.
That’s bad enough on its own, but the logic for attaching isAssignedToMe
must also live in its own reusable function because inlining the logic inside fetchBoards
makes it inaccessible to the new task
query. Tack on a few more rounds of iteration to your schema, and you’ll find that you’ve wound up with spaghetti: complex imperative code, interspersed with implicitly coupled helper functions that are impossible to quickly grok.
The solution to this problem is to lazily resolve all of your relations. All GraphQL servers have great support for this out of the box.
How GraphQL resolvers work
Recall the GraphQL schema we came up with at the start of this post. In it we defined a type named Query
, and this type contained definitions for all of our query operations. By default, Query
is the “root” query type of a schema.
What that means is when you send a query operation to a GraphQL server like below:
query GetBoardById($id: ID!) { board(id: $id) { id }}
The server interprets it as selecting the board
field from the Query
type1. That board
field then might return us a Board
type, at which point the server will recurse down through the operation and see that we selected the id
field on that Board
type. There’s nothing special about how GraphQL queries execute—it’s the exact same field selection system that is used to define your desired response payload.
To serve a selected field, the GraphQL server will call the resolver function you defined for that field. When we call Pothos’ queryType
method we are defining resolvers for each field on the Query
type, so it’s obvious which function gets called in that case. This is much clearer to see if we write our resolvers in the format expected by Apollo server:
const resolvers = { Query: { board: (parent, args, ctx) => { const boards = await fetchBoards(ctx.db, { id: args.id }); return boards[0]; }, boards: (parent, args, ctx) => fetchBoards(ctx.db, { title: { search: args.input } }), },}
When we select board(id: 4)
, the Apollo server will execute that selection by running await resolvers.Query.board(..., { id: '4' }, ctx)
.
It’s far less obvious what function is being called for a field like Board.id
because we never defined one. In this case, most GraphQL servers will use a default resolver function. The specifics of this default function may vary depending on your server as it’s not defined in the GraphQL specification, but in general you can interpret the default resolver function as simply returning parent[fieldName]
. More concretely, when GraphQL resolves our Board.id
field it looks something like this under the hood:
const makeDefaultResolver = (fieldName: string) => (parent, args, ctx) => parent[fieldName]; const resolvers = { Query: { // <snip> }, Board: { id: makeDefaultResolver('id'), // ... },}; // resolving `board`:const ctx = await yourContextFactory();const board = await resolvers.Query.board(null, { id: '4' }, ctx);const resolvedBoardType = { id: await resolvers.Board.id(board, {}, ctx), title: await resolvers.Board.title(board, {}, ctx), // ...}; return resolvedBoard;
If it’s possible for us to define resolvers for the Query
type and there’s nothing special about the Query
type, it stands to reason we can define resolvers for any type in our schema. If we wanted to make all Board.title
values uppercase, then we can easily do so. Note that Pothos does not have a “default resolver function” concept, so for Board.id
to be queryable we must manually expose it:
const resolvers = { Query: { board: (parent, args, ctx) => { const boards = await fetchBoards(ctx.db, { id: args.id }); return boards[0]; }, // ... }, Board: { title: (parent) => parent.title.toUpperCase(), },};
Thinking about how this looks at runtime, it’s the exact same as we saw before:
const board = await resolvers.Query.board(null, { id: '4' }, ctx);const resolvedBoardType = { id: await resolvers.Board.id(board, {}, ctx), title: await resolvers.Board.title(board, {}, ctx), // ...};return resolvedBoardType;
This works recursively all throughout the operation. No matter how deep a selected field is, your GraphQL server will run the field’s resolver function if one is defined.
Of course in the real world, GraphQL servers are smart enough to run all of your field resolvers in parallel. They’re also all executed lazily, which means you only pay for them when the consumer specifically requests the field they’re tied to. The resolver for Board.id
isn’t going to be executed if the API consumer didn’t actually ask for the field.
This execution model should directly inform how we implement our resolvers.
Leaning in to GraphQL’s execution model
Let’s restructure our resolvers so that everything gets resolved lazily. Note how in the following code block there are zero joins being issued to the database:
builder.queryType({ fields: (t) => ({ board: { // ... resolve: (parent, args, ctx) => ctx.db.boards.findUnique({ where: { id: args.id }, }), }, boards: { // ... resolve: (parent, args, ctx) => ctx.db.boards.findMany({ where: { title: { search: input.query }, }, }), }, }),}); builder.objectType('Board', { fields: (t) => ({ columns: { type: [Board], nullable: { list: false, items: false, }, resolve: (parent, args, ctx) => ctx.db.boardColumns.findMany({ where: { boardId: parent.id }, orderBy: { index: 'asc' }, }), }, }),}); builder.objectType('BoardColumn', { fields: (t) => ({ tasks: { columns: { type: [Task], nullable: { list: false, items: false, }, resolve: (parent, args, ctx) => ctx.db.tasks.findMany({ where: { boardColumnId: parent.id, }, orderBy: { index: 'asc' }, }), }, }),}); builder.objectType('Task', { fields: (t) => ({ id: t.exposeID('id', {}), // ... // custom field with business logic: isAssignedToMe: t.boolean({ nullable: false, resolve: (parent, args, ctx) => parent.assigneeId === ctx.auth.userId, }), }),});
In this implementation, we have centralized all logic for resolving each schema type alongside the definition of the type itself. It doesn’t matter how we end up reaching a field typed as a Task
; the exact same field resolver logic will run. Engineers don’t need to remember to manually call an addIsAssignedToMe
function before returning Task
s.
In addition to that huge win, this code is also much easier to test. We can write tests which target specific parts of our schema (like Task.isAssignedToMe
) in isolation as opposed to being forced into testing one giant fetchBoards
function.
Centralized type resolver
This pattern is especially useful when there are large differences between your data model and your GraphQL schema. Many a GraphQL server contains a class of function called resolve${Type}
that takes in a database Foo
and resolves it to a GraphQL Foo
. Lots of engineers understand that it’s a good idea to structure resolvers so that joins are resolved easily, only to then fumble and put their database marshaling functions in the wrong place:
builder.objectType('BoardColumn', { fields: (t) => ({ tasks: { columns: { type: [Task], nullable: { list: false, items: false, }, resolve: (parent, args, ctx) => { const databaseTasks = ctx.db.tasks.findMany({ where: { boardColumnId: parent.id, }, orderBy: { index: 'asc' }, }); // ❌ very bad! return databaseTasks.map((task) => resolveTask(task)); }, }, }),});
As soon as you’ve done this, you’re back to square one. Engineers on the team need to always remember to call this function everywhere they’re trying to return task objects to the client, and inevitably someone will make a mistake. Type-checking won’t save you in cases where your resolveTask
function is responsible for resolving a nullable field—most GraphQL servers will happily accept undefined
in place of a real value.
The fix is to push the resolveTask
function call down to the Task
resolver, which centralizes the logic. The following code snippet is verbose, but gets the idea across:
const resolvers = { Query: { // ... }, Task: { // ... description: (parent, args, ctx) => // assumption: `parent` is a raw `task` database record resolveTask(parent).description, someOtherCustomField: (parent, args, ctx) => resolveTask(parent).someOtherCustomField, // ... },};
You likely want to memoize the resolveTask
call to save CPU cycles, and a helper function for cutting down on the boilerplate is nice too. Both Apollo and Pothos operate on plain JavaScript objects, which makes writing such a helper function reasonably straightforward. Not so much for a more complicated server framework like NestJS GraphQL; the exact same set of Task
resolvers looks like this in that framework:
@Resolver(() => Task)class TaskResolver { @ResolveField('description') description(@Parent() parent) { return resolveTask(parent).description; } // repeat for all other fields}
It’s still possible to write a metaprogram for generating all of the needed field resolvers, but it’s far more annoying than for the Pothos case where you can simply spit out a fields
object. If you’ve configured NestJS GraphQL to use Apollo server under the hood, then you also unfortunately inherit Apollo’s default resolver function. Default resolver functions are a real impediment for this use case, because if you forget to write a field resolver yourself then you don’t get a loud failure when a consumer tries requesting that field.
Takeaways
- You should almost never eagerly resolve relations in GraphQL. It creates ongoing maintenance burden and potential performance problems.
- If you have a field like
BoardColumn.tasks: [Task!]!
then the resolver function forBoardColumn.tasks
should be as dumb as possible. Such resolvers should return raw data, leaving final resolution of that data up to theTask
type itself. - More generally: think about your types as a graph of connected nodes. Only resolve one “hop” away from where you currently are in the graph.
- Defaults matter, and default GraphQL resolver functions are a bad idea. Use libraries like Pothos which force you to explicitly write each field’s resolver to avoid surprises at runtime.
- Note that if you prefer, you can call your root query type whatever you’d like to using the
schema
keyword. Shopify calls theirsQueryRoot
, for instance.↩