---
title: Resolvers
source: 'https://academia.sh/en/courses/api-design/resolvers'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:30+00:00'
license: 'CC BY-SA 4.0'
---

# Resolvers

The resolver's four inputs, what the default resolver does, execution proceeding level by level, and measuring the resolver-call count and data-source round-trip count a query produces.

The engine called a resolver for every field, but how resolvers are written was not
discussed. A separate resolver had been written for the loan record's `member` field, and
none for its `id` field; the difference between the two is the backbone of GraphQL
execution.

This lesson defines the resolver signature, shows what the default resolver does, and
counts how many resolver calls a query produces. That last number will give rise to the
next lesson's topic.

## The Resolver's Four Inputs

A resolver is the function that produces a single field's value, and it takes four
inputs.

- **Source** is the value the parent level returned. The source of the `Loan.member`
  resolver is the loan record one level up. Root fields have no source.
- **Args** are the field's arguments; variables have already been resolved by this point,
  and the resolver never sees anything like `$id`.
- **Context** is data shared across the whole request: credentials, a database
  connection, a per-request cache. It is the only horizontal channel carried between
  fields.
- **Info** says where the call is being made: which field, which parent type, which path
  in the response.

How the engine produces these four is shown below. Two things are new: the `info` object
and the `tracer` hook that every call is reported to.

```js
// executor.mjs — runs a validated document
// Every resolver gets four inputs:
//   source   the value the parent level returned
//   args     the field's arguments (variables resolved)
//   context  data shared across the request
//   info     { field, parentType, path } — identifies the call in place
import { parseType, fieldDef, possibleTypes } from "./schema.mjs";

const LEAF = new Set(["scalar", "enum"]);

function resolveVariables(op, given) {
  const d = {};
  for (const b of op.variables) {
    const v = given[b.name] ?? b.defaultValue;
    if (v === undefined && b.type.endsWith("!")) throw new Error(`variable $${b.name} is required`);
    d[b.name] = v;
  }
  return d;
}

const resolveArgs = (args, vars) =>
  Object.fromEntries(Object.entries(args).map(([a, d]) => [a, d.kind === "variable" ? vars[d.name] : d.value]));

function flatten(schema, document, typeName, selection, acc = []) {
  for (const s of selection) {
    if (s.kind === "field") { acc.push(s); continue; }
    const fragment = s.kind === "spread" ? document.fragments[s.name] : s;
    const condition = s.kind === "spread" ? fragment.type : s.type;
    if (condition === typeName || possibleTypes(schema, condition).includes(typeName)) flatten(schema, document, typeName, fragment.selection, acc);
  }
  return acc;
}

async function executeSelection(o, typeName, selection, source, path, sequential = false) {
  const fields = flatten(o.schema, o.document, typeName, selection);
  const result = {};
  for (const f of fields) result[f.alias] = null;      // key order comes from the query
  const run = async (f) => { result[f.alias] = await executeField(o, typeName, f, source, `${path}.${f.alias}`); };
  if (sequential) for (const f of fields) await run(f);
  else await Promise.all(fields.map(run));
  return result;
}

async function executeField(o, typeName, field, source, path) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const custom = o.resolvers[typeName]?.[field.name];
  const resolver = custom ?? ((k) => k?.[field.name]);       // default resolver: reads the field of the same name
  const info = { field: field.name, parentType: typeName, path, isDefault: !custom };
  o.tracer?.(info);
  const value = await resolver(source, resolveArgs(field.args, o.vars), o.context, info);
  return complete(o, def.type, value, field.selection, path);
}

async function complete(o, typeText, value, selection, path) {
  if (value === null || value === undefined) return null;
  const t = parseType(typeText);
  if (t.list) return Promise.all(value.map((d, i) => complete(o, t.name, d, selection, `${path}[${i}]`)));
  const type = o.schema.types[t.name];
  if (LEAF.has(type.kind)) return value;
  // Interface and union types ask the concrete-type resolvers.
  const concrete = type.kind === "object" ? t.name : o.resolvers.__type[t.name](value);
  return executeSelection(o, concrete, selection, value, path);
}

export async function execute(schema, resolvers, document, { operationName, variables = {}, context = {}, tracer } = {}) {
  const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0];
  const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context, tracer };
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  return { data: await executeSelection(o, root, op.selection, null, "", op.type === "mutation") };
}
```

## The Default Resolver

The one decisive line in `executeField` is this: if no resolver has been written, the
engine uses the `(k) => k?.[field.name]` function. This is the **default resolver**, and
it reads the field of the same name from the source.

No resolver has been written for the loan record's `id`, `status`, and `returnDate`
fields, because the object coming from the data source already carries these fields
under the same names. One has been written for the `member` field, because the record
only holds a `memberId`, and turning it into a member object requires a lookup.

The rule is this: **a resolver is written only where the data's shape and the schema's
shape diverge.** This dramatically reduces the resolver count when the data source
resembles the schema, and it pushes the schema toward becoming a copy of the data source
— a tendency worth watching, because the schema is the interface the consumer sees, not
the store's.

The data source below counts every access separately, so that resolver calls and
data-source round trips can be told apart.

```js
// data.mjs — in-memory data; every access is counted
export const counter = { book: 0, member: 0, loan: 0 };

const BOOKS = new Map([
  ["978-0262033848", { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", copies: 1 }],
  ["978-0201896831", { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", copies: 2 }],
  ["978-0131103627", { isbn: "978-0131103627", title: "The C Programming Language", author: "Kernighan", copies: 3 }],
]);
const MEMBERS = new Map([
  ["U-1001", { id: "U-1001", createdAt: "2024-02-11", name: "Alice Carter" }],
  ["U-1002", { id: "U-1002", createdAt: "2025-06-03", name: "Dana Reyes" }],
]);
const LOANS = new Map([
  ["O-1", { id: "O-1", createdAt: "2026-01-04", memberId: "U-1001", status: "OPEN", returnDate: "2026-03-20",
            items: [{ isbn: "978-0262033848", branch: "central" }, { isbn: "978-0201896831", branch: "shore" }] }],
  ["O-2", { id: "O-2", createdAt: "2026-01-19", memberId: "U-1002", status: "OPEN", returnDate: "2026-04-02",
            items: [{ isbn: "978-0131103627", branch: "hill" }] }],
  ["O-3", { id: "O-3", createdAt: "2026-02-02", memberId: "U-1001", status: "CLOSED", returnDate: "2026-02-28",
            items: [{ isbn: "978-0201896831", branch: "central" }] }],
]);

// Single-record accesses: every call counts as one "data source round trip."
export const getBook = (isbn) => { counter.book++; return BOOKS.get(isbn); };
export const getMember = (id) => { counter.member++; return MEMBERS.get(id); };
export const listLoans = (memberId) => { counter.loan++; return [...LOANS.values()].filter((o) => !memberId || o.memberId === memberId); };
export const getLoan = (id) => { counter.loan++; return LOANS.get(id); };
export const reset = () => { counter.book = 0; counter.member = 0; counter.loan = 0; };

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: {
    loan: (_, a) => getLoan(a.id) ?? null,
    loans: () => listLoans(),
    member: (_, a) => getMember(a.code) ?? null,
  },
  // No resolver for id, createdAt, status, returnDate: the default resolver does the job.
  Loan: { member: (o) => getMember(o.memberId) },
  Item: { book: (k) => getBook(k.isbn) },
  Member: { loans: (u) => listLoans(u.id) },
};
```

## The Shape of Execution

```js
// run.mjs — traces resolver calls, shows their order, and counts them
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS, counter, reset } from "./data.mjs";

async function trace(title, query, verbose) {
  const calls = [];
  reset();
  const result = await execute(SCHEMA, RESOLVERS, parse(query), { tracer: (b) => calls.push(b) });

  console.log(`\n=== ${title} ===`);
  if (verbose) {
    for (const b of calls) console.log(`  ${(b.parentType + "." + b.field).padEnd(16)}${(b.isDefault ? "default" : "custom").padEnd(9)} ${b.path}`);
  } else {
    // Group by depth: depth, call count, which fields
    const level = new Map();
    for (const b of calls) {
      const d = b.path.split(".").length - 1;
      if (!level.has(d)) level.set(d, []);
      level.get(d).push(`${b.parentType}.${b.field}`);
    }
    console.log("  depth  calls  fields");
    for (const [d, list] of [...level].sort((a, b) => a[0] - b[0])) {
      console.log(`  ${String(d).padStart(5)}  ${String(list.length).padStart(5)}  ${[...new Set(list)].join(", ")}`);
    }
  }
  const custom = calls.filter((b) => !b.isDefault).length;
  console.log(`  total resolver calls: ${calls.length} (custom ${custom}, default ${calls.length - custom})`);
  console.log(`  data source round trips: book=${counter.book} member=${counter.member} loan=${counter.loan}  total=${counter.book + counter.member + counter.loan}`);
  return result;
}

await trace("small query", `{ loans { id member { name } } }`, true);
const result = await trace("large query", `{ loans { id status member { name } items { branch book { title author } } } }`, false);
console.log(`\nfirst record: ${JSON.stringify(result.data.loans[0])}`);
```

```
=== small query ===
  Query.loans     custom    .loans
  Loan.id         default   .loans[0].id
  Loan.member     custom    .loans[0].member
  Loan.id         default   .loans[1].id
  Loan.member     custom    .loans[1].member
  Loan.id         default   .loans[2].id
  Loan.member     custom    .loans[2].member
  Member.name     default   .loans[0].member.name
  Member.name     default   .loans[1].member.name
  Member.name     default   .loans[2].member.name
  total resolver calls: 10 (custom 4, default 6)
  data source round trips: book=0 member=3 loan=1  total=4

=== large query ===
  depth  calls  fields
      1      1  Query.loans
      2     12  Loan.id, Loan.status, Loan.member, Loan.items
      3     11  Member.name, Item.branch, Item.book
      4      8  Book.title, Book.author
  total resolver calls: 32 (custom 8, default 24)
  data source round trips: book=4 member=3 loan=1  total=8

first record: {"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central","book":{"title":"Introduction to Algorithms","author":"Cormen"}},{"branch":"shore","book":{"title":"The Art of Computer Programming","author":"Knuth"}}]}
```

The small query's trace directly shows the shape of execution. First the root field
resolves. Then the `id` and `member` fields of **all three** loan records resolve. Only
after those finish do the `Member.name` calls begin.

Execution proceeds not depth by depth but **level by level**. The reason is the
`Promise.all` call inside `executeSelection`: every sibling field at one level is
started together, and none of them moves to a level below until all of them finish. This
is the property the next lesson's batch loading will rest on: if the calls at the same
level are already made at the same time, they can be merged.

Two constraints force this order. A resolver's source is the value the parent level
returned; a child cannot resolve before its parent does. And there is no ordering
guarantee among sibling fields; because query fields are assumed independent, they cannot
depend on each other's result.

## Two Separate Numbers

The large query produced 32 resolver calls but reached the data source only 8 times. The
two numbers are separate and measure different things.

The resolver-call count comes from the **shape of the query**: the product of the number
of selected fields and the lengths of the lists. Twenty-four of them are default
resolvers; these read a field from an in-memory object, and their cost is negligible.

The data-source round-trip count, on the other hand, comes from the **written
resolvers**, and this is where the real cost is. The distribution of the eight round
trips is striking: one loan-list call, three member calls, four book calls. Yet the
database holds only two distinct members and three distinct books.

The same member appears in two loan records and so was fetched twice; the same book
appears in two items and so was fetched twice. As the list grows longer, this number
grows with it: a hundred loan records mean a hundred member calls, even if most of the
members are the same person.

## Summary

- A resolver takes four inputs: the source the parent level returned, the resolved args,
  the context shared across the request, and the info that identifies the call's place.
- The default resolver reads the field of the same name from the source; a resolver is
  written only where the data's shape and the schema's shape diverge.
- Execution proceeds level by level, not depth by depth: every sibling field at one level
  is started together, and none moves to the level below until all finish.
- The order arises from two constraints: a child level needs the value its parent
  returned, and sibling fields cannot depend on each other's result.
- The resolver-call count comes from the shape of the query, the data-source round-trip
  count from the written resolvers; the two are separate measures.
- The same member and the same book get fetched more than once; this number grows
  together with the list length.

## Next Step

The number in the last paragraph is the name of a problem. Three member calls were made
for three loan records, yet there were only two distinct members; four book calls were
made, yet there were only three distinct books. When the list grows to a hundred records,
there will be a hundred and one round trips: one list query plus one for every record.
The next lesson names this problem, writes a batch-loading layer that rests on the fact
that calls made at the same level are already started together, and measures how far the
round-trip count drops.
