Lesson 28 / 34
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.
Contents
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.memberresolver 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.
// 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.
// 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
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.