Dataloaders with @nestjs/graphql in a Federated Setup

In a any GraphQL architecture, handling N+1 queries is a requirement. But if you’re using @nestjs/graphql, there’s a surprising gap in end-to-end examples that cover how to integrate dataloaders within the full NestJS lifecycle, especially in a federated schema. In this post, I’ll walk through a complete implementation, highlight the call chain, and explain why NestJS gives us a stable foundation, unlike other technologies where we would need to hand-roll a framework.

The Case for Dataloaders

Federated GraphQL introduces a new level of indirection. The GraphQL server doesn’t own the data it serves, it delegates. That means common access patterns, like resolving a User for each AuditLog, can easily generate N+1 service calls if the developer is unaware of the call graph.

Dataloaders let us batch and cache lookups during a single GraphQL request. But where do they live? And how do we inject them into a resolver that might be executed deep inside a federated field chain?

Let’s look at a practical example: associating an AuditLog entry with its User. We'll integrate a dataloader across the service, resolver, and context layers in a way that fits naturally into Nest’s modular structure.

The High-Level Architecture

We’ll follow a familiar NestJS pattern:

  • Resolver: Where we define the GraphQL fields.

  • Service: Where business logic and database calls live.

  • Model/Entity: The data shape.

  • Context: Where we inject per-request dependencies like dataloaders.

The glue that makes this work is NestJS's DI system. We’ll wire up a GraphQLContextFactory to construct a custom context object for every request, and use that to pass in our dataloaders.

Step 1: Define the Dataloader

In users.dataloader.ts, we use the familiar dataloader library to batch user lookups.

@Injectable()
export class UsersDataLoader {
  private readonly logger = new Logger(UsersDataLoader.name);

  constructor(private readonly usersService: UsersService) {}

  createLoader() {
    return new DataLoader<string, User | null>(async (userIds) => {
      const result = await this.usersService.findAll({ userId: userIds });
      const userMap = new Map(result.data.map(u => [u.userId, u]));

      return userIds.map(id => userMap.get(id) || null);
    });
  }
}

This loader maps an array of user IDs to user objects. Through the context we’ll set up next, batching is per-request. Users loaded across resolvers are still coalesced.

Step 2: Construct the GraphQL Context

The context is created once per GraphQL request and gives access to the request object, the session, and, in our case, the dataloader.

/src/common/graphql-context/graphql-context.factory.ts

@Injectable()
export class GraphQLContextFactory {
  constructor(private readonly usersDataLoader: UsersDataLoader) {}

  create(req: Request): GraphqlContext {
    return {
      req,
      usersDataLoader: this.usersDataLoader.createLoader(),
    };
  }
}

Here, we create a new loader instance per request to ensure caching is scoped correctly.

And the corresponding module:

/src/common/graphql-context/graphql-context.module.ts

@Module({
  imports: [UsersModule],
  providers: [GraphQLContextFactory],
  exports: [GraphQLContextFactory],
})
export class GraphQLContextModule {}

Step 3: Register the Context in app.module.ts

GraphQLModule.forRootAsync<ApolloFederationDriverConfig>({
  driver: ApolloFederationDriver,
  imports: [GraphQLContextModule],
  inject: [GraphQLContextFactory],
  useFactory: (contextFactory: GraphQLContextFactory) => ({
    autoSchemaFile: { federation: 2 },
    context: ({ req }) => contextFactory.create(req),
    // other settings
  }),
}),

Now every resolver can access context.usersDataLoader and trust that it's per-request and batched.

Step 4a: Use It in a Resolver

For an easy example, let’s assume that AuditLog and User are part of the same subgraph. Let’s resolve the user field on an AuditLog entry using the dataloader.

@Resolver(() => AuditLog)
export class AuditLogResolver {
  constructor(private readonly auditLogService: AuditLogService) {}

  @ResolveField(() => User, { nullable: true })
  async user(
    @Parent() log: AuditLog,
    @Context() context: GraphqlContext
  ): Promise<User | null> {
    if (!log.userId) return null;

    const user = await context.usersDataLoader.load(log.userId);
    return user;
  }
}

This resolver may run many times in a single request—once per audit log. Thanks to dataloader, the underlying call to usersService.findAll() runs just once.

Step 4b: (Federated Example) Nesting a User from Another Subgraph

In a federated GraphQL setup, it's common to expose key entities like User from a central subgraph so they can be referenced elsewhere. For example, an AuditLog entity in the audit-log-service may expose a userId, and we want to resolve that into a full User object.

To support this, we implement @ResolveReference on the users subgraph:

@Resolver(() => User)
export class UsersResolver {
  @ResolveReference()
  async resolveReference(
    reference: { __typename: string; userId: string },
    context: GraphqlContext
  ): Promise<User | null> {
    const user = await context.usersDataLoader.load(reference.userId);
    return user;
  }
}

This pattern allows any other subgraph to request a User via:

extend type User @key(fields: "userId") {
  userId: ID! @external
  name: String @requires(fields: "userId")
}

And then use:

type AuditLog {
  userId: ID!
  user: User @provides(fields: "userId")
}

Federation Call Graph: One Request, Many Resolvers

Let’s break down what actually happens at runtime:

1. Client Sends Query to Router

Suppose a frontend sends a query like:

query { auditLogs { id action user { name } } }

2. Router Orchestrates the Subgraphs

The federated router (Apollo Gateway, Hive Router, Cosmo Router, etc.) decomposes the query into subqueries for each subgraph. For example:

  • Ask audit-log-service for audit log entries with userId

  • Ask user-service to resolve each User from those IDs

3. NestJS Subgraph Executes @ResolveReference

In the user-service, the router sends a single request with multiple __typename: User references, like:

[ { "__typename": "User", "userId": "user_1" }, { "__typename": "User", "userId": "user_2" }, { "__typename": "User", "userId": "user_1" } ]

Then @nestjs/graphql calls our @ResolveReference resolver once per entry.

Crucially, all of these happen in the same request context, which means they share the same dataloader instance. That’s the key.

4. Dataloader Fans In

The usersDataLoader.load(userId) calls queue up the IDs (user_1, user_2, etc.) and batch them into a single database or service call thanks to dataloader.

So, instead of N individual usersService.findById() calls, we make one call like:

this.usersService.findAll({ userId: ['user_1', 'user_2'] })

Which gets fanned back out and resolved appropriately.

Why NestJS Excels Here

Other GraphQL libraries like graphql-yoga or graphql-http provide flexibility, but not structure. With NestJS, the 3-tier model (Resolver → Service → Model) gives us a predictable, maintainable approach.

More importantly, NestJS’s DI system lets us inject service-backed loaders into context factories with minimal ceremony. No manually wire up caches or manage per-request instances. Nest modules do that for you.

Integrating dataloader into a federated @nestjs/graphql app is one of those small investments that pay dividends at scale. It aligns with the GraphQL spirit—ask for exactly what you want, efficiently—and sits in NestJS’s service-oriented structure.

In a world where resolvers span services and teams, minimizing redundant service calls is table stakes for sane architecture.

Next
Next

Scaling Node Applications: Playing Defense