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-servicefor audit log entries withuserIdAsk
user-serviceto resolve eachUserfrom 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.