Skip to content
NestJS ns graphql 4 min read

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 posts field 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.

ConcernRecommendation
Caching scopeScope.REQUEST — one cache per operation, never a singleton
Result orderingMap keys back to results explicitly; never assume DB order
Missing keysReturn null/[] for absent keys so the array length matches
One-to-one vs one-to-manyUse DataLoader<K, V> vs DataLoader<K, V[]> and group accordingly
Multiple relationsCo-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 @ResolveField instead 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.REQUEST to cache within a request and isolate data between users.
  • Keep batch functions order-preserving: build a Map keyed by the lookup key, then map over 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.
Last updated June 14, 2026
Was this helpful?