Building code-first resolvers with decorators
The NestJS GraphQL Module generates the entire GraphQL schema automatically from TypeScript classes and decorators, instead of maintaining a separate SDL file. ObjectType, Field, Resolver and the Args decorator together form a code-first approach where type safety stays intact all the way from the database model to the GraphQL response, including autocompletion and refactoring support in the IDE.
Table of contents
- 1. Code-first vs. schema-first in NestJS
- 2. Setting up GraphQLModule with the Apollo driver
- 3. ObjectType and Field decorators
- 4. Resolver classes with Query and Mutation
- 5. Input types and validation with class-validator
- 6. DataLoader integration against N+1
- 7. Guards and interceptors for auth in resolvers
- 8. Subscriptions with PubSub
- 9. NestJS GraphQL vs. standalone Apollo Server
- 10. Summary
- 11. FAQ
1. Code-first vs. schema-first in NestJS
The NestJS GraphQL Module supports two approaches: schema-first, where SDL files are maintained separately and synced with TypeScript interfaces via code generation, and code-first, where TypeScript classes with decorators are the sole source of truth and the schema is generated automatically at runtime. In practice, code-first has become the standard approach for NestJS projects, because it eliminates the double maintenance of an SDL file and TypeScript types.
The decisive advantage of code-first with decorators: if a field changes in the TypeScript class, for example a renamed property, the generated GraphQL schema changes automatically along with it, with no manual sync step. The TypeScript compiler and the IDE catch inconsistencies immediately, because the schema and the application code are the same source. This article focuses consistently on the code-first approach, since it's the recommended and community-dominant choice for new NestJS projects.
2. Setting up GraphQLModule with the Apollo driver
The foundation of every NestJS GraphQL API is the GraphQLModule, which connects to the actual GraphQL execution engine through a driver, usually ApolloDriver. With autoSchemaFile, NestJS generates the SDL representation automatically from the decorators and optionally writes it to a file in the project, effectively a readable reference document that never needs manual maintenance.
Important configuration options include the context callback, which makes request-specific data such as the authenticated user or DataLoader instances available to every resolver, and playground or Apollo Sandbox for interactive development. For production deployments, introspection and the playground should be disabled by default, except for internal, controlled APIs where schema transparency is actually desired.
// app.module.ts — GraphQLModule with Apollo Driver, code-first schema generation
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "path";
import { ProductModule } from "./product/product.module";
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), "src/schema.gql"),
sortSchema: true,
playground: process.env.NODE_ENV !== "production",
context: ({ req }: { req: Request }) => ({ req }),
}),
ProductModule,
],
})
export class AppModule {}
3. ObjectType and Field decorators
The @ObjectType() decorator turns a TypeScript class into a GraphQL type, and every property annotated with @Field() becomes a GraphQL field. NestJS reads the TypeScript type via reflection and infers the matching GraphQL scalar or type from it, but that only works reliably for primitive types. For more complex cases, such as arrays or explicit nullable declarations, the GraphQL type has to be given explicitly in the decorator, because TypeScript's type information isn't fully preserved at runtime.
A common gotcha: @Field() without an explicit type only works reliably for string, number, boolean and enums. For arrays of object types, for example a list of linked categories, the type has to be passed as a callback function, @Field(() => [Category]), to avoid circular import issues between mutually referencing types, a pattern taken directly from Angular's dependency injection system.
// product.type.ts — ObjectType decorator generates the GraphQL type automatically
import { ObjectType, Field, ID, Float } from "@nestjs/graphql";
import { Category } from "../category/category.type";
@ObjectType()
export class Product {
@Field(() => ID)
id: string;
@Field()
sku: string;
@Field()
name: string;
@Field(() => Float)
price: number;
@Field(() => [Category])
categories: Category[];
@Field({ nullable: true })
discountedPrice?: number;
}
4. Resolver classes with Query and Mutation
Resolver classes carry the @Resolver(() => Product) decorator and define individual fields as methods with @Query() for reads and @Mutation() for writes. NestJS's dependency injection container is fully available here, so repository or service classes get injected via the constructor just like normal, exactly as in REST controllers. This consistency between the REST and GraphQL layers is one of the biggest practical advantages of the NestJS approach over a standalone Apollo Server setup.
The @ResolveField() decorator defines additional fields not yet directly resolved in the ObjectType, for example computed values or relationships that should only be loaded lazily for performance reasons, when actually requested. Combined with the @Parent() decorator, which accesses the already-resolved parent value, this achieves a clean separation between simple data fields and more expensive, derived fields.
// product.resolver.ts — Resolver with injected service, query and mutation
import { Resolver, Query, Mutation, Args, ResolveField, Parent } from "@nestjs/graphql";
import { Product } from "./product.type";
import { CreateProductInput } from "./dto/create-product.input";
import { ProductService } from "./product.service";
import { CategoryService } from "../category/category.service";
@Resolver(() => Product)
export class ProductResolver {
constructor(
private readonly productService: ProductService,
private readonly categoryService: CategoryService,
) {}
@Query(() => [Product])
products(): Promise<Product[]> {
return this.productService.findAll();
}
@Query(() => Product, { nullable: true })
product(@Args("sku") sku: string): Promise<Product | null> {
return this.productService.findBySku(sku);
}
@Mutation(() => Product)
createProduct(@Args("input") input: CreateProductInput): Promise<Product> {
return this.productService.create(input);
}
@ResolveField(() => Number, { nullable: true })
discountedPrice(@Parent() product: Product): number | undefined {
return this.productService.applyActiveDiscount(product);
}
}
5. Input types and validation with class-validator
For mutation arguments, NestJS defines dedicated @InputType() classes, which are structurally similar to ObjectTypes but semantically represent incoming data. Combined with class-validator decorators like @IsString(), @Min() or @IsEmail() directly on the input type properties, a globally registered ValidationPipe automatically validates every incoming mutation before the resolver even runs, exactly as with REST controllers in NestJS.
This pattern saves considerable code compared to manual validation in the resolver body and ensures consistent error formats across the whole API. If validation fails, NestJS automatically converts the thrown BadRequestException into a matching GraphQL error format, including a list of the individual validation failures per field under the extensions object.
// create-product.input.ts — InputType with class-validator decorators
import { InputType, Field, Float } from "@nestjs/graphql";
import { IsString, IsNotEmpty, Min, MaxLength } from "class-validator";
@InputType()
export class CreateProductInput {
@Field()
@IsString()
@IsNotEmpty()
@MaxLength(64)
sku: string;
@Field()
@IsString()
@IsNotEmpty()
name: string;
@Field(() => Float)
@Min(0)
price: number;
}
6. DataLoader integration against N+1
As with any GraphQL implementation, nested fields without batching lead to the N+1 problem: a list of products, each with resolved categories, produces one database query per product instead of a single bundled query. In NestJS, the DataLoader pattern is typically implemented as a request-scoped provider that creates a fresh DataLoader instance per request and makes it available to all resolvers through the GraphQLModule's context callback.
The request scope matters, because DataLoader instances must never be shared across requests, otherwise data from different users gets mixed up, or stale cached values persist beyond the actual request. The DataLoader itself collects every requested ID within one event-loop tick and resolves them in a single findByIds query, before mapping the results back to the original promises.
// category.loader.ts — request-scoped DataLoader batching category lookups
import { Injectable, Scope } from "@nestjs/common";
import DataLoader from "dataloader";
import { CategoryService } from "./category.service";
import { Category } from "./category.type";
@Injectable({ scope: Scope.REQUEST })
export class CategoryLoader {
private readonly loader: DataLoader<string, Category[]>;
constructor(private readonly categoryService: CategoryService) {
this.loader = new DataLoader(async (productIds: readonly string[]) => {
const byProduct = await this.categoryService.findByProductIds([...productIds]);
return productIds.map((id) => byProduct[id] ?? []);
});
}
load(productId: string): Promise<Category[]> {
return this.loader.load(productId);
}
}
7. Guards and interceptors for auth in resolvers
NestJS's guard system works identically for GraphQL resolvers as for REST controllers, with one detail: the ExecutionContext has to be converted to the GraphQL-specific context via GqlExecutionContext.create() to reach the request, args or the authenticated user. A @UseGuards(GqlAuthGuard) decorator at the resolver level protects entire classes or individual methods this way, using exactly the same pattern as in the REST layer.
Interceptors work well for cross-cutting concerns like logging, performance measurement, or automatically enriching response data, regardless of whether the request came in over REST or GraphQL. This reusability of NestJS's core concepts across both protocols is a central reason why many teams prefer NestJS over a standalone Apollo Server setup when GraphQL and REST coexist in the same project.
8. Subscriptions with PubSub
For real-time functionality, the NestJS GraphQL Module uses the @Subscription() decorator pattern combined with a PubSub implementation, a simple in-memory variant by default for development, and usually graphql-redis-subscriptions in production for distribution across multiple server instances. A resolver publishes events via pubSub.publish(), while the subscription method returns an async iterator that NestJS automatically forwards to connected clients over WebSockets.
For production setups with multiple horizontally scaled server instances, the in-memory PubSub variant is unsuitable, because events are only visible within the same Node.js instance. Redis as a shared message broker solves that problem, with all instances subscribing to the same pub/sub topic so events get distributed across instances, regardless of which instance triggered the original mutation event.
9. NestJS GraphQL vs. standalone Apollo Server
Whether the detour through NestJS pays off, or a direct Apollo Server setup is enough, depends heavily on how much structure a project already needs.
| Criterion | NestJS GraphQL Module | Standalone Apollo Server |
|---|---|---|
| Dependency injection | Built in, consistent with REST | Configured manually |
| Schema generation | Automatic from decorators | Manual (SDL or a code-first library) |
| REST + GraphQL in one project | Natively supported | Needs an extra framework layer |
| Entry effort | Higher, more concepts | Lower for pure GraphQL projects |
| Guards, interceptors, pipes | Reusable across protocols | Has to be built yourself |
For pure, small GraphQL microservices with no REST portion, a lean Apollo Server setup is often faster to get running. For larger projects that already use NestJS as the backend framework, or run REST and GraphQL side by side, the advantage of a consistent architecture with reusable guards, interceptors and dependency injection clearly wins out.
Mironsoft
TypeScript backends and GraphQL architecture with NestJS
Building a structured GraphQL API with NestJS?
We design resolver architecture, input validation and DataLoader strategy for your NestJS GraphQL backend, wire up guards for auth correctly, and deliver a type-safe schema from the database all the way to the response.
Schema architecture
Structuring and typing ObjectTypes, resolvers and modules cleanly
Performance
Request-scoped DataLoader against N+1 problems in nested queries
Auth & realtime
Guards for auth and Redis-backed subscriptions for real-time updates
10. Summary
The NestJS GraphQL Module in the code-first approach generates the entire schema automatically from TypeScript classes and decorators like @ObjectType(), @Field(), @Resolver() and @InputType(). Instead of manually syncing SDL and TypeScript types, a single source of truth is preserved, with full IDE support and automatic error detection via the TypeScript compiler.
Request-scoped DataLoader providers cleanly solve N+1 problems within NestJS's dependency injection system, guards and interceptors work identically for REST and GraphQL, and class-validator decorators on input types save manual validation code. For projects already using NestJS as the backend framework, or running REST and GraphQL side by side, the code-first approach with decorators is the obvious, consistent choice.
NestJS GraphQL Module — Key Takeaways
Code-first decorators
@ObjectType(), @Field(), @Resolver() generate the schema automatically from TypeScript classes.
Validation
@InputType() plus class-validator decorators validate mutations automatically before the resolver runs.
DataLoader
Request-scoped providers prevent N+1 database queries on nested fields.
Guards & subscriptions
GqlExecutionContext for auth guards, Redis PubSub for subscriptions across instances.