1 February, 20258 minute read

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:

schema.graphql
Click to copy
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:

pothos-server.ts
Click to copy
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:

pothos-server.ts
Click to copy
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:

get-board.graphql
Click to copy
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:

apollo-server.ts
Click to copy
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:

apollo-server.ts
Click to copy
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:

apollo-server.ts
Click to copy
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:

Click to copy
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:

pothos-server.ts
Click to copy
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 Tasks.

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:

pothos-server.ts
Click to copy
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:

apollo-server.ts
Click to copy
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:

nest-server.ts
Click to copy
@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

  1. You should almost never eagerly resolve relations in GraphQL. It creates ongoing maintenance burden and potential performance problems.
  2. If you have a field like BoardColumn.tasks: [Task!]! then the resolver function for BoardColumn.tasks should be as dumb as possible. Such resolvers should return raw data, leaving final resolution of that data up to the Task type itself.
  3. 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.
  4. 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.


  1. Note that if you prefer, you can call your root query type whatever you’d like to using the schema keyword. Shopify calls theirs QueryRoot, for instance.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com