The Resolver's Four Parameters
The Resolver's Four Parameters
Every resolver is invoked as resolver(parent, args, context, info). The two questions of nested querying — which row do I fetch and which filter applies — are answered by the first two parameters, and getting them backwards is the most common bug in new GraphQL servers.
const resolvers = {
Query: {
// Root field: no parent object exists yet, so arg1 is the root value (usually undefined)
post: (_parent, { id }, { db }) => db.post.findUnique({ where: { id } }),
},
Post: {
// Nested field: parent is exactly what Query.post returned
comments: (post, { limit, orderBy }, { db }) =>
db.comment.findMany({
where: { postId: post.id }, // foreign key arrives on the parent
orderBy: { createdAt: orderBy ?? 'DESC' },
take: limit ?? 20,
}),
},
Comment: {
author: (comment, _args, { loaders }) => loaders.user.load(comment.authorId),
},
};
| Call site | parent | args |
|---|---|---|
Query.post |
root value (undefined) |
{ id: "42" } |
Post.comments |
the resolved post | { limit: 5 } |
Comment.author |
the resolved comment | {} |
Two rules follow directly from the table:
- The parent carries the foreign key.
Post.commentsnever receivespostIdfrom the client — the post already holds it. Design each resolver's return value so the next level can find its way: a comment missingauthorIdmeansComment.authorreadsundefinedand the field resolves tonullor throws, with no validation error anywhere. - args are per-field, per-call.
comments(limit: 5)under one post andcomments(limit: 50)under another in the same document produce different resolver calls with different second arguments.
See field arguments design for deciding which arguments belong on a nested field versus at the root.
Arguments That Filter the Relationship
Arguments That Filter the Relationship
Arguments live on fields, not on types, so the same relationship can be filtered differently at every call site:
type Post {
id: ID!
comments(limit: Int = 20, orderBy: Order = NEWEST, approvedOnly: Boolean = false): [Comment!]!
}
query Preview {
post(id: "42") {
topComments: comments(limit: 3, orderBy: TOP) { body }
recentNotes: comments(limit: 5, orderBy: NEWEST) { body }
}
}
Two selections, one field, two independent resolver calls. Defaults make an argument optional; a non-null declaration (limit: Int!) forces every caller to pass it, which is how you encode "this list must be bounded" in the schema rather than in a code comment.
Because the args land in the resolver's second parameter, filtering belongs in the data layer:
comments: (post, { limit, orderBy, approvedOnly }, { db }) =>
db.comment.findMany({
where: { postId: post.id, ...(approvedOnly && { approved: true }) },
orderBy: { createdAt: orderBy === 'NEWEST' ? 'desc' : 'score' },
take: limit,
}),
Loading every comment for the post and then calling .filter() in JavaScript scales with the table; take plus a where clause scales with the page.
The null question, settled. Arguments never rescue missing data. If a parent object lacks the property a child resolver reads, the resolver receives undefined, and one of three things happens: the field resolves to null (nullable field), the error propagates to the nearest nullable ancestor (non-null field), or the resolver throws a typed error. Which one you get is decided by null propagation, not by validation — the document and schema were in agreement the whole time.
Nulls, Loaders, and the Parent You Never Re-fetch
Nulls, Loaders, and the Parent You Never Re-fetch
A nested resolver already holds a resolved parent, so fetching it again by id is wasted work — and not reusing the ids it carries is where the N+1 pattern begins.
// Comment.author runs once per comment: 12 comments = 12 SELECTs
author: (comment, _args, { loaders }) =>
loaders.user.load(comment.authorId), // deduplicated across the whole request
loaders comes from context, which the server builds per request:
const server = new ApolloServer({
schema,
context: ({ req }) => ({
userId: authenticate(req), // request-scoped fact, not row identity
loaders: createLoaders(db), // one DataLoader set per request
}),
});
Rules that keep parent and argument flow correct in production:
- Never share loaders across requests. A module-level loader caches for the process lifetime and can serve one user's row to another; a per-request instance caches only for that request, which is the entire point.
- Distinguish two kinds of "id". The authenticated user id in context is a request argument applied to every lookup;
post.idon the parent is a relationship argument. Confusing them produces queries that quietly ignore ownership. - Fail at the boundary, not two levels down. If a child depends on a parent property, assert it in the parent resolver. A silent
nulldiscovered in a rendered template is the hardest class of bug to trace. - Watch for hidden re-fetches.
author: (comment) => db.post.findUnique({ where: { id: comment.postId } })inside aPost-scoped resolver means the parent row was loaded, discarded, and loaded again.
Interview angle: draw the four parameters and sort them — args comes from the client document, context from the request, parent from the data already in hand, info from the executor — then explain which one you would use for a foreign key and which for the current user.
Practice Problems
Fill in the Post.comments resolver so it filters by the parent post id, applies the limit and approvedOnly arguments in the database query, and explains what happens at runtime if the incoming post object carries no id property.
Example:
Input: type Post { id: ID! comments(limit: Int = 20, approvedOnly: Boolean = false): [Comment!]! } const resolvers = { Post: { comments: (parent, args, ctx) => { /* ... */ }, }, };
Output: const resolvers = { Post: { comments: (parent, { limit, approvedOnly }, { db }) => db.comment.findMany({ where: { postId: parent.id, ...(approvedOnly && { approved: true }), }, take: limit, }), }, };
The foreign key comes from the parent, never from the query, and the arguments arrive as the second parameter. Filtering happens in the database with take/where instead of loading the whole relation and trimming it in JavaScript.
Optimal Solution — O(n) time, O(n) space
Read identity from the parent, read intent from args, read infrastructure from context — one line each, no re-fetch of the parent row.
const resolvers = {
Post: {
comments: (parent, { limit, approvedOnly }, { db }) =>
db.comment.findMany({
where: {
postId: parent.id,
...(approvedOnly && { approved: true }),
},
take: limit,
orderBy: { createdAt: 'desc' },
}),
},
}; Edge Cases:
- Missing parent.id — assert it in the parent resolver rather than debugging a null two levels down
- approvedOnly false spreads nothing, so the where clause stays on postId alone
- limit defaults to 20 when the client omits it, so an unbounded relation still pages
Quiz
1. A Post.comments resolver needs the post id to fetch its comments. Where does that id come from?
2. In post(id: "42") { comments { author { name } } }, what is the parent passed to Comment.author?
3. A comment row arrives without an authorId property. What happens?
Flashcards
Question
Resolver signature
Click to reveal answer
Answer
resolver(parent, args, context, info) — parent carries the row above, args carries the field arguments, context is request-scoped.
Question
Where a nested foreign key comes from
Click to reveal answer
Answer
The parent object, never the query. Child resolvers read parent.<fk> and do not ask the client.
Question
Missing parent property
Click to reveal answer
Answer
Undefined reaches the child resolver: null on a nullable field, propagating error on a non-null one. Not a validation error.
Question
args vs context
Click to reveal answer
Answer
args vary per field call from the document; context is built once per request (auth user, loaders, data sources).
Question
Loader lifetime rule
Click to reveal answer
Answer
Create loaders inside the per-request context. A global loader leaks cache across users and requests.
Revision Notes
Key Takeaways
- 1. parent, args, context, info: relationship data comes from the parent, caller intent from args, request facts from context
- 2. Field arguments filter relationships inside the data layer — take/where, not JavaScript filter after loading
- 3. Missing parent data is a runtime failure that surfaces as null or an error, never as a schema validation failure
- 4. Request-scoped loaders are how parent ids turn into batched lookups instead of one query per child
Interview Tips
- • Name all four resolver parameters and give one concrete example of what belongs in each
- • Explain why passing postId from the client for a nested list is wrong, both for caching and for authorization
- • Describe the loader lifetime: created per request, keyed by foreign key, disposed when the request ends
Cheat Sheet
Parent and Argument Flow Cheat Sheet
resolver(parent, args, context, info)
| Parameter | Comes from | Typical use |
|---|---|---|
parent |
resolver one level up | parent.id / parent.authorId |
args |
field arguments in the document | limit, orderBy, filters |
context |
built per request | auth user, loaders, data sources |
info |
executor | path, return type, selected fields |
- Root fields get the root value as parent (usually
undefined) - Filter in the query:
take+where, never load-then-filter in JS - Missing parent property →
undefined→ null or error at execution; validation never sees data - One DataLoader set per request:
context: () => ({ loaders: createLoaders(db) })