Field Resolvers & Relations
GraphQL lets clients traverse relationships — a User and their posts, a Post and its author — in a single request. Rather than eagerly joining every relation in your top-level query, NestJS lets you resolve related fields lazily with @ResolveField, computing them only when a client actually asks. The catch is the infamous N+1 problem: a list of 50 users each triggering one query for posts means 51 round trips. The fix is DataLoader, which batches and caches those lookups into a single call per tick.
Resolving a field with @ResolveField
A field resolver is a method on a type’s resolver that produces one field of that type. Decorate the class with @Resolver(() => User) so Nest knows which object type the field belongs to, then mark the method with @ResolveField. The @Parent decorator injects the already-resolved parent object so you can read its keys.
import { Resolver, ResolveField, Parent, Query, Args, Int } from '@nestjs/graphql';
import { User } from './models/user.model';
import { Post } from '../posts/models/post.model';
import { UsersService } from './users.service';
import { PostsService } from '../posts/posts.service';
@Resolver(() => User)
export class UsersResolver {
constructor(
private readonly usersService: UsersService,
private readonly postsService: PostsService,
) {}
@Query(() => [User])
users(): Promise<User[]> {
return this.usersService.findAll();
}
@ResolveField('posts', () => [Post])
getPosts(@Parent() user: User): Promise<Post[]> {
return this.postsService.findByAuthor(user.id);
}
}
Because getPosts runs once per User in the result, querying { users { id posts { title } } } over 50 users fires 50 separate findByAuthor calls — that is the N+1 problem in action.
Keep the
postsfield off the persistence layer’s eager fetch. The whole point of a field resolver is that it runs only when the client selects that field.
Spotting the N+1 explosion
With SQL logging on, the cost is obvious: one query loads the users, then a follow-up query fires for each row.
Output:
SELECT * FROM users LIMIT 50;
SELECT * FROM posts WHERE author_id = 1;
SELECT * FROM posts WHERE author_id = 2;
SELECT * FROM posts WHERE author_id = 3;
... (47 more)
The goal is to collapse those 50 follow-up queries into a single WHERE author_id IN (...).
Batching with DataLoader
DataLoader collects the keys requested during one event-loop tick, hands them to a batch function as an array, and distributes the results back. The batch function must return an array the same length and order as the input keys.
npm install dataloader
import { Injectable, Scope } from '@nestjs/common';
import DataLoader from 'dataloader';
import { Post } from './models/post.model';
import { PostsService } from './posts.service';
@Injectable({ scope: Scope.REQUEST })
export class PostsLoader {
constructor(private readonly postsService: PostsService) {}
readonly byAuthorId = new DataLoader<number, Post[]>(
async (authorIds: readonly number[]) => {
const posts = await this.postsService.findByAuthorIds([...authorIds]);
const grouped = new Map<number, Post[]>();
for (const post of posts) {
const bucket = grouped.get(post.authorId) ?? [];
bucket.push(post);
grouped.set(post.authorId, bucket);
}
// Preserve key order; default to [] so every key has a result.
return authorIds.map((id) => grouped.get(id) ?? []);
},
);
}
Scope.REQUEST is essential: a loader caches per request, so a new instance must be created for each incoming GraphQL operation. A singleton loader would leak cached data across users.
The underlying service issues one batched query:
@Injectable()
export class PostsService {
constructor(private readonly repo: Repository<Post>) {}
findByAuthorIds(authorIds: number[]): Promise<Post[]> {
return this.repo.find({ where: { authorId: In(authorIds) } });
}
}
Wiring the loader into the resolver
Inject the request-scoped loader and call .load(key). DataLoader queues every .load made in the same tick and fires the batch function once.
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly postsLoader: PostsLoader) {}
@ResolveField('posts', () => [Post])
getPosts(@Parent() user: User): Promise<Post[]> {
return this.postsLoader.byAuthorId.load(user.id);
}
}
Register the loader as a provider in the module alongside the resolver:
@Module({
providers: [UsersResolver, PostsService, PostsLoader],
})
export class UsersModule {}
Now the same { users { id posts { title } } } query produces just two SQL statements:
Output:
SELECT * FROM users LIMIT 50;
SELECT * FROM posts WHERE author_id IN (1,2,3,...,50);
Structuring loaders at scale
As relations multiply, group loaders in a single request-scoped provider so resolvers depend on one collaborator instead of many.
| Concern | Recommendation |
|---|---|
| Caching scope | Scope.REQUEST — one cache per operation, never a singleton |
| Result ordering | Map keys back to results explicitly; never assume DB order |
| Missing keys | Return null/[] for absent keys so the array length matches |
| One-to-one vs one-to-many | Use DataLoader<K, V> vs DataLoader<K, V[]> and group accordingly |
| Multiple relations | Co-locate loaders in a *Loaders provider injected per resolver |
A common bug: the batch function returns fewer items than keys passed in. DataLoader rejects with
returned a Promise of an array of a different length— always map over the original keys.
Best practices
- Resolve relations with
@ResolveFieldinstead of eager joins so clients pay only for the fields they select. - Always back relation fields with DataLoader; a naive field resolver reintroduces N+1 the moment it appears inside a list.
- Make loaders
Scope.REQUESTto cache within a request and isolate data between users. - Keep batch functions order-preserving: build a
Mapkeyed by the lookup key, thenmapover the input keys. - Push the actual
IN (...)query into a service method; the loader should only batch, group, and cache. - Type loaders precisely (
DataLoader<number, Post[]>) so the array-vs-scalar shape is enforced by the compiler. - Group related loaders into a single provider to keep resolver constructors lean as the schema grows.