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 withuserId
Ask
user-service
to resolve eachUser
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.