Skip to content

GraphQL Nested Resolvers — Resolver Chains and Data Fetching Patterns

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Graphql Nested Resolvers. We cover key concepts, practical examples, and best practices to help you master this topic.

GraphQL nested resolvers form a resolver chain where each parent resolver passes its result to child field resolvers, enabling efficient data fetching across related types.

What You'll Learn

You will learn how the resolver chain works, how to implement resolvers for nested fields, the default resolver behavior, and strategies for optimizing nested data fetching.

Why Nested Resolvers Matter

GraphQL's power comes from its ability to fetch deeply nested related data in one query. A single query can fetch a user, their devices, each device's threats, and each threat's details — all through resolver chains. Without understanding nested resolvers, you either over-fetch (loading everything in one resolver) or under-fetch (making multiple round trips). DodaTech's Durga Antivirus Pro dashboard queries user { devices { threats { name severity } } } in one request, with each level resolved by its own focused resolver.

sequenceDiagram
    participant Client
    participant GraphQL
    participant DeviceResolver
    participant ThreatResolver
    participant DB
    
    Client->>GraphQL: query { user { devices { threats { name } } } }
    GraphQL->>DeviceResolver: Query.user() → returns { id, devices: [...] }
    DeviceResolver-->>GraphQL: user object
    GraphQL->>DeviceResolver: User.devices(parent) → parent is user
    DeviceResolver->>DB: SELECT * FROM devices WHERE user_id = parent.id
    DB-->>DeviceResolver: device list
    DeviceResolver-->>GraphQL: devices
    GraphQL->>ThreatResolver: Device.threats(parent) → parent is device
    ThreatResolver->>DB: SELECT * FROM threats WHERE device_id = parent.id
    DB-->>ThreatResolver: threats
    ThreatResolver-->>GraphQL: threats
    GraphQL-->>Client: { data: { user: { devices: [...] } } }
â„šī¸ Info

Prerequisites: GraphQL queries and resolver basics.

Resolver Chain — How It Works

const resolvers = {
  Query: {
    user: (parent, args, context) => {
      // parent is null for root Query resolvers
      return context.db.users.findById(args.id);
    },
  },
  User: {
    // parent is the user object returned by the Query.user resolver
    devices: (parent, args, context) => {
      return context.db.devices.findByUserId(parent.id);
    },
    threats: (parent, args, context) => {
      return context.db.threats.findByUserId(parent.id);
    },
  },
  Device: {
    // parent is a device from the User.devices resolver
    user: (parent, args, context) => {
      return context.db.users.findById(parent.userId);
    },
    threats: (parent, args, context) => {
      return context.db.threats.findByDeviceId(parent.id);
    },
  },
};

The Default Resolver

If you don't define a resolver for a field, GraphQL uses the default resolver — it looks for a property with the same name on the parent object:

// Schema
type User {
  id: ID!
  name: String!
  email: String!
}

// Without explicit resolvers, GraphQL does:
// User.id → parent.id
// User.name → parent.name
// User.email → parent.email

// This works as long as your database fields match your schema fields

The default resolver is convenient but fails for:

  • Computed fields (full name from first + last)
  • Related data (devices from a user)
  • Fields with different names (database user_name → schema name)

Nested Resolver with Arguments

type User {
  id: ID!
  name: String!
  threats(severity: Severity, limit: Int = 10): [Threat!]!
}
const resolvers = {
  User: {
    threats: (parent, args, context) => {
      const { severity, limit } = args;
      let userThreats = context.db.threats
        .filter(t => t.userId === parent.id);
      
      if (severity) {
        userThreats = userThreats.filter(t => t.severity === severity);
      }
      
      return userThreats.slice(0, limit);
    },
  },
};
# Query with nested resolver argument
query GetUserCriticalThreats($userId: ID!) {
  user(id: $userId) {
    name
    threats(severity: CRITICAL, limit: 5) {
      id
      name
      detectedAt
    }
  }
}

# Expected response
{
  "data": {
    "user": {
      "name": "Alice Smith",
      "threats": [
        { "id": "thr-001", "name": "Emotet", "detectedAt": "2026-06-28T10:00:00Z" }
      ]
    }
  }
}

Avoiding N+1 in Nested Resolvers

// N+1 PROBLEM: Each device triggers a separate DB query
const resolvers = {
  User: {
    devices: (parent, args, context) => {
      return context.db.devices.findByUserId(parent.id);
    },
  },
  Device: {
    threats: (parent, args, context) => {
      // Called N times for N devices — N+1 queries!
      return context.db.threats.findByDeviceId(parent.id);
    },
  },
};

// SOLUTION: DataLoader batches all threat queries
const resolvers = {
  Device: {
    threats: (parent, args, context) => {
      // DataLoader batches multiple load() calls into one query
      return context.threatLoader.load(parent.id);
    },
  },
};

Common Mistakes

1. Assuming All Fields Need Resolvers

Simple field access (id, name) works fine with default resolvers. Only write resolvers for computed fields, related data, or fields needing transformation.

2. Not Handling the N+1 Problem

Every nested resolver that queries the database individually creates N+1 queries. Always use DataLoader for list child relationships.

3. Over-fetching in Parent Resolvers

Loading all related data in the parent resolver defeats GraphQL's field selection optimization. Let each field resolver fetch only what it needs.

4. Deeply Nested Resolvers Without Caching

A chain like user → devices → threats → alerts → logs creates 5+ resolver levels. Cache intermediate results with DataLoader to avoid redundant queries.

5. Mutating Parent State in Child Resolvers

Child resolvers should not modify the parent object. Resolvers are read operations (except mutations). Unexpected side effects in field resolvers cause hard-to-find bugs.

Practice Questions

  1. What is the parent argument in a resolver?
  2. When does the default resolver apply?
  3. What causes the N+1 problem in nested resolvers?
  4. How do you pass arguments to nested field resolvers?
  5. What is the resolver execution order for deeply nested queries?

Answers:

  1. The parent argument is the return value of the parent resolver. For User.devices, parent is the User object from the Query.user resolver.
  2. The default resolver applies when no explicit resolver is defined for a field. It looks for a property with the same name on the parent object.
  3. When a parent resolver returns N items, and a child resolver queries the database once per item, you get 1 query (parent) + N queries (children) = N+1 total queries.
  4. Define arguments in the schema on the nested field: devices(status: DeviceStatus). The resolver receives these as the second argument.
  5. Depth-first, left-to-right. A parent resolver completes before its children start. Sibling fields may resolve in parallel (depends on server implementation).

Challenge: Design an optimized resolver chain for DodaTech's dashboard: query { dashboard(userId: ID!) { user { devices { threats { details } } } } }. Implement with DataLoader at each level to avoid N+1 queries. Add field-level arguments for filtering and pagination at each nesting level.

FAQ

Can a resolver access sibling field values?

Not directly — resolvers only receive their parent's value, not sibling values. If you need data from a sibling, restructure your types or use a higher-level resolver.

Do all resolvers in a chain execute?

Yes — but only for fields the client requested. If the client doesn't request threats, the Device.threats resolver never executes. This is GraphQL's built-in optimization.

How deep can resolver chains go?

There's no hard limit, but depth limits (7-10 levels) are recommended for security. Each level adds latency, and malicious queries can create deep chains to overload the server.

Can I skip resolvers for simple fields?

Yes — if a field name matches a property on the parent object, the default resolver handles it. Only add explicit resolvers when you need custom logic.

What happens if a resolver returns undefined?

GraphQL treats undefined as null. If the field is non-nullable, null propagates to the parent. Always return explicit values from resolvers.

Mini Project

Build a resolver chain for DodaTech's multi-tenant dashboard. Types: Organization → Teams → Users → Devices → Threats → Alerts. Each level supports field-level arguments for filtering. Implement DataLoader at each relationship and ensure the chain is optimized to avoid N+1 queries.

What's Next

Topic Description
DataLoader (N+1 Problem) Batching and caching database queries
Batching Strategies Grouping operations for performance
Error Handling Error patterns in GraphQL
âŦ… Subscriptions Deep Dive
➡ DataLoader Guide

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro