GraphQL Nested Resolvers â Resolver Chains and Data Fetching Patterns
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: [...] } } }
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â schemaname)
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
- What is the parent argument in a resolver?
- When does the default resolver apply?
- What causes the N+1 problem in nested resolvers?
- How do you pass arguments to nested field resolvers?
- What is the resolver execution order for deeply nested queries?
Answers:
- The parent argument is the return value of the parent resolver. For
User.devices, parent is the User object from theQuery.userresolver. - 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.
- 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.
- Define arguments in the schema on the nested field:
devices(status: DeviceStatus). The resolver receives these as the second argument. - 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
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 |
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro