Skip to content
intermediate Phase 5 · Nested Selections and Object Relationships

Parent Objects and Argument Flow

How parent values and arguments combine to fetch exactly the requested relationship.

40m
1 problems
Topic Progress 0%

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.comments never receives postId from the client — the post already holds it. Design each resolver's return value so the next level can find its way: a comment missing authorId means Comment.author reads undefined and the field resolves to null or throws, with no validation error anywhere.
  • args are per-field, per-call. comments(limit: 5) under one post and comments(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.id on 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 null discovered 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 a Post-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

0 / 1 solved
Complete the parent-scoped comments resolver
Resolver parent/args flow

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?

Question 1 options

2. In post(id: "42") { comments { author { name } } }, what is the parent passed to Comment.author?

Question 2 options

3. A comment row arrives without an authorId property. What happens?

Question 3 options

Flashcards

Question

Resolver signature

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

Answer

The parent object, never the query. Child resolvers read parent.<fk> and do not ask the client.

Question

Missing parent property

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

Answer

args vary per field call from the document; context is built once per request (auth user, loaders, data sources).

Question

Loader lifetime rule

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) })

Continue Learning