Code-first GraphQL, resolvers and DataLoader
Code-first GraphQL generates the SDL from @ObjectType/@Resolver classes. Field resolvers create the N+1; a per-request DataLoader batches keys into one DB call. Harden prod with depth/complexity limits.
The GraphQL endpoint shipped clean and the demo query was instant: one author, with their posts. Then the dashboard team wrote authors { id posts { title } } over the full list, and Postgres lit up — 1 query for the 200 authors, then 200 more, one per author, to fetch each author’s posts. p99 went from 40ms to 6 seconds, and the connection pool started queueing. Nothing in the resolver was “wrong”; the posts field resolver simply ran once per parent, and 200 parents meant 200 round-trips. This is the GraphQL N+1, and it is the single most common way a typed graph API melts a database. The fix is not a join — it is batching the keys collected in one tick into a single call.
Code-first: the schema is generated from your classes
In the code-first approach you never hand-write SDL. You decorate TypeScript classes with @ObjectType() and @Field(), decorate resolver methods with @Query() / @Mutation() / @ResolveField(), and Nest generates the schema from that type information at startup. autoSchemaFile writes the SDL to disk (or keeps it in memory) so tooling can read it — but the source of truth is your code. The schema-first alternative is the inverse: you write the SDL by hand and generate the TypeScript typings from it. Code-first wins when your team lives in TypeScript and wants the types and the schema to never drift apart.
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
import { Module } from '@nestjs/common';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql', // GENERATE SDL from the TS classes
playground: false, // off in prod
introspection: process.env.NODE_ENV !== 'production', // hide the schema in prod
}),
],
})
export class AppModule {}The object types are plain classes; @Field(() => Int) pins the GraphQL scalar where TS reflection can’t infer it (numbers, arrays, nullability):
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Post {
@Field(() => Int) id: number;
@Field() title: string;
}
@ObjectType()
export class Author {
@Field(() => Int) id: number;
@Field({ nullable: true }) firstName?: string;
@Field(() => [Post]) posts: Post[]; // a relation -> resolved lazily by a field resolver
}Resolvers and the field resolver — where the N+1 is born
A @Resolver(() => Author) class hosts the root operations (@Query, @Mutation) and, crucially, field resolvers (@ResolveField) that compute a field lazily, per parent object. The posts field on Author is not loaded with the author — it is resolved only if the query asks for it, by calling the field resolver once for every author in the result set. That per-parent execution is exactly the N+1: one query for N authors, then N separate queries for their posts.
import { Resolver, Query, ResolveField, Parent, Args, Int } from '@nestjs/graphql';
@Resolver(() => Author)
export class AuthorsResolver {
constructor(
private readonly authors: AuthorsService,
private readonly posts: PostsService,
) {}
@Query(() => [Author])
authors() {
return this.authors.findAll(); // 1 query -> N authors
}
@ResolveField(() => [Post])
async postsFor(@Parent() author: Author) {
return this.posts.findByAuthorId(author.id); // fires ONCE PER AUTHOR -> the N+1
}
}DataLoader: batch the keys collected in one tick
DataLoader sits between the field resolver and the database. Instead of querying immediately, every load(id) call enqueues a key; at the end of the current event-loop tick, DataLoader hands the whole batch of keys to one findByAuthorIds([...]) and fans the result back out to each caller. It also memoises within the batch, so two load(5) calls hit the DB once. The collapse is dramatic: 1 + N queries become 1 + 1.
The loader must be per request — its cache holds request data and must never leak across users — so wire it as a request-scoped provider and resolve it in the GraphQL context:
import * as DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST }) // a fresh loader per request -> no cross-request cache
export class PostsLoader {
constructor(private readonly posts: PostsService) {}
readonly byAuthorId = new DataLoader<number, Post[]>(async (authorIds) => {
// ONE round-trip for every key collected in this tick:
const rows = await this.posts.findByAuthorIds(authorIds as number[]);
const byId = new Map<number, Post[]>(authorIds.map((id) => [id, []]));
for (const p of rows) byId.get(p.authorId)!.push(p);
return authorIds.map((id) => byId.get(id)!); // MUST return results in key order
});
}@ResolveField(() => [Post])
postsFor(@Parent() author: Author) {
return this.loader.byAuthorId.load(author.id); // enqueue a key; batched, not per-call
}The batch function’s contract is strict: it must return an array the same length and order as the input keys, mapping a missing key to [] (or null), never silently dropping it — get the order wrong and authors get each other’s posts.
Context, guards, and production hardening
Guards, interceptors, and filters still work in GraphQL — but if you try to use them exactly as you would in an HTTP controller, you’ll find they silently fail to read request args. The resolver argument shape differs (root, args, context, info), so you adapt the generic ExecutionContext with GqlExecutionContext.create(context) to read args or the request:
import { GqlExecutionContext } from '@nestjs/graphql';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class GqlAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const { req } = ctx.getContext(); // the request lives on the GraphQL context
return Boolean(req?.user);
}
}A single endpoint that accepts arbitrary queries is a denial-of-service surface: a deeply nested posts { author { posts { author ... } } } can explode into millions of field resolves. Production graphs cap this with a query depth limit and a complexity budget — assign each field a cost and reject any operation over a ceiling before it executes:
import { Plugin } from '@nestjs/apollo';
import { GraphQLSchemaHost } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';
@Plugin()
export class ComplexityPlugin {
constructor(private readonly schemaHost: GraphQLSchemaHost) {}
async requestDidStart() {
const max = 20, { schema } = this.schemaHost;
return {
didResolveOperation: async ({ request, document }) => {
const complexity = getComplexity({
schema, query: document, variables: request.variables,
operationName: request.operationName,
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
});
if (complexity > max) throw new GraphQLError(`Query too complex: ${complexity} > ${max}`);
},
};
}
}Add persisted queries (clients send a hash of a pre-registered query, not arbitrary text) and disable introspection in prod so attackers can’t trivially map the whole graph.
| Concern | Tool | Mechanism | Failure it prevents |
|---|---|---|---|
| Schema | code-first autoSchemaFile | SDL generated from @ObjectType classes | types and schema drifting apart |
| Relation | @ResolveField | resolves a field lazily, per parent | over-fetching unrequested relations |
| N+1 | per-request DataLoader | batch keys in one tick -> 1 call | 1 + N round-trips, pool exhaustion |
| Cross-cut | GqlExecutionContext | adapt ctx for guards/interceptors | guards that can’t read args/req |
| DoS | depth + complexity limits | reject costly query before it runs | a nested query melting the DB |
▸Why this works
Why does DataLoader live per request and not as a singleton? Its in-batch cache is what makes batching work, but that cache holds the rows it just fetched — author 5’s posts, user 12’s profile. A singleton loader would serve request B the rows it cached for request A, leaking one user’s data into another’s response and never reflecting a write that happened between requests. A fresh loader per request keeps the batching win while scoping the cache to a single, authenticated unit of work. That is why you register it Scope.REQUEST (or build it in the GraphQL context factory) — one loader, one request, discarded at the end.
A query returns N authors, each resolving a `posts` field. The naive resolver fires one DB query per author (1 + N). How should you resolve the relation in a graph API?
A query asks for 50 authors and each author's posts via a @ResolveField. With a correctly wired per-request DataLoader, how many database queries run?
In code-first NestJS GraphQL, where does the GraphQL schema (SDL) come from, and how do guards read GraphQL args?
- 01Explain the GraphQL N+1 problem in a Nest resolver and exactly how a DataLoader fixes it, including why the loader must be request-scoped.
- 02Contrast code-first and schema-first GraphQL in Nest, and name the production hardening you add to a public graph endpoint.
Code-first GraphQL in Nest generates the schema from your TypeScript: @ObjectType/@Field classes and @Resolver methods (@Query, @Mutation, @ResolveField) become the SDL via autoSchemaFile, so types and schema never drift — the inverse of schema-first, where you hand-write SDL and generate typings. The senior trap is the field resolver: @ResolveField resolves a relation lazily, once per parent, so a query over N authors each resolving their posts fires 1 + N database queries — the GraphQL N+1 that exhausts the connection pool at list scale. The fix is a DataLoader: the field resolver calls load(id), keys collected in one event-loop tick are batched into a single findByAuthorIds([…]) call and memoised within the request, collapsing 1 + N into 1 + 1; the batch function must return results in the same order as the keys. The loader is registered Scope.REQUEST (or built in the GraphQL context) so its cache scopes to one request and never leaks across users. Guards, interceptors, and filters still work — you adapt the context with GqlExecutionContext.create(context) to reach args and the request. Finally, because one endpoint accepts arbitrary queries, you harden production with query depth and complexity limits that reject a costly operation before it runs, persisted queries, and disabled introspection. Now when you see p99 spike on a list query, you’ll know to reach for DataLoader first — and to check the complexity budget before shipping a new relation.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.