Hook
By Day 64, you've likely built REST APIs in NestJS and felt the friction of over-fetching and under-fetching data. GraphQL with NestJS, code-first shifts that burden: you write plain TypeScript classes decorated with @ObjectType and @Field, and the schema generates itself. No SDL wrestling, no manual type duplication—your source of truth is your code.
What Is Code-First GraphQL?
Code-first means you define your GraphQL types using TypeScript decorators, not by writing SDL (Schema Definition Language) strings. The schema emerges from your classes. This approach keeps your types DRY and lets your IDE understand the entire graph immediately.
Compare the two workflows:
- Schema-first: Write SDL, generate TypeScript types, sync them manually.
- Code-first: Write TypeScript, decorators generate SDL automatically.
For teams working in TypeScript across the stack, code-first eliminates duplication and keeps refactoring painless.
Decorating Classes with @ObjectType and @Field
Start with a concrete example. Imagine you're building a CRM: you have Lead objects with an ID, name, and email.
import { ObjectType, Field, ID } from '@nestjs/graphql';
@ObjectType()
export class Lead {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field()
email: string;
}
That's it. The @ObjectType() decorator marks the class as a GraphQL type. Each @Field() becomes a field in your schema. The generated SDL looks like this:
type Lead {
id: ID!
name: String!
email: String!
}
Notice @Field(() => ID)—the arrow function tells NestJS what GraphQL type to use. For primitives like string, number, and boolean, NestJS infers the type automatically. For custom types or nullable fields, you pass the type explicitly:
@ObjectType()
export class Lead {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field({ nullable: true })
phone?: string;
@Field(() => [Contact])
contacts: Contact[];
}
Here, phone is optional (nullable), and contacts is an array of the Contact type. The schema handles arrays and nullability without you writing a single SDL character.
Resolvers—@Query and @Mutation Methods
Now that you have types, you need a resolver to actually return data. Resolvers are class methods decorated with @Query or @Mutation. They're how clients fetch and modify data.
Create a resolver:
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { Lead } from './lead.model';
import { LeadService } from './lead.service';
@Resolver(() => Lead)
export class LeadResolver {
constructor(private leadService: LeadService) {}
@Query(() => Lead, { nullable: true })
async lead(@Args('id') id: string): Promise<Lead | null> {
return this.leadService.findById(id);
}
@Query(() => [Lead])
async leads(): Promise<Lead[]> {
return this.leadService.findAll();
}
@Mutation(() => Lead)
async createLead(
@Args('name') name: string,
@Args('email') email: string,
): Promise<Lead> {
return this.leadService.create({ name, email });
}
}
The @Resolver(() => Lead) decorator associates this resolver with the Lead type. Each @Query method generates a root query field; each @Mutation method generates a root mutation field. Arguments are passed via @Args.
For complex input objects (like creating a Lead with many fields), use @InputType:
import { InputType, Field } from '@nestjs/graphql';
@InputType()
export class CreateLeadInput {
@Field()
name: string;
@Field()
email: string;
@Field({ nullable: true })
phone?: string;
}
@Mutation(() => Lead)
async createLead(@Args('input') input: CreateLeadInput): Promise<Lead> {
return this.leadService.create(input);
}
This keeps your mutation signature clean and your schema maintainable as the input grows.
Field Resolvers—Resolving Relations Lazily
Often your Lead has related data—say, a list of Interaction records. Instead of always loading interactions with every lead, you can define a separate field resolver that only runs when the client explicitly requests it.
@ObjectType()
export class Lead {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field(() => [Interaction])
interactions: Interaction[];
}
@Resolver(() => Lead)
export class LeadResolver {
constructor(private leadService: LeadService) {}
@Query(() => Lead, { nullable: true })
async lead(@Args('id') id: string): Promise<Lead | null> {
return this.leadService.findById(id);
}
@FieldResolver(() => [Interaction])
async interactions(@Parent() lead: Lead): Promise<Interaction[]> {
return this.leadService.loadInteractions(lead.id);
}
}
The @FieldResolver decorator tells NestJS: "This method resolves the interactions field of Lead." It only runs when a client queries for that field, saving database queries.
Watch for N+1 queries. If your client requests 100 leads and their interactions, you'll run 1 query for leads + 100 queries for interactions—a classic N+1 problem. Mitigate this with DataLoader, a batch-loading utility:
import * as DataLoader from 'dataloader';
@Injectable()
export class InteractionLoader {
constructor(private leadService: LeadService) {}
createLoader(): DataLoader<string, Interaction[]> {
return new DataLoader(async (leadIds: string[]) => {
const map = await this.leadService.loadInteractionsBatch(leadIds);
return leadIds.map(id => map[id] || []);
});
}
}
@FieldResolver(() => [Interaction])
async interactions(
@Parent() lead: Lead,
@Context('interactionLoader') loader: DataLoader<string, Interaction[]>,
): Promise<Interaction[]> {
return loader.load(lead.id);
}
DataLoader batches requests, so 100 leads trigger 1 batch query instead of 100 individual queries. This is essential at scale.
When REST Is Better Than GraphQL
GraphQL isn't a silver bullet. A few scenarios favor REST:
- Simple CRUD APIs: If you're exposing a single resource with straightforward read/write operations, REST's clarity often outweighs GraphQL's flexibility.
- Public, cache-friendly APIs: REST's HTTP caching (ETags, cache headers) is built-in and well-understood. GraphQL over HTTP uses POST, which complicates caching.
- File uploads at scale: GraphQL multipart uploads are clunky. REST handles them cleanly.
- Simple frontends: If your client is a basic webpage or mobile app with fixed data needs, the overhead of learning GraphQL queries isn't worth it.
GraphQL shines when you have multiple, varied frontends (web, mobile, admin dashboard, third-party partners) all with different data requirements. Instead of building separate endpoints for each, one GraphQL API lets each client fetch exactly what it needs.
In my experience, I've used GraphQL for a trading platform where the web app, mobile app, and reporting dashboard all queried different subsets of order and position data. A REST API would have spawned 15+ endpoints; GraphQL kept it to one.
Common Mistakes
1. Forgetting to enable GraphQL in your module.
You must import and configure GraphQL in your root module:
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
}),
],
})
export class AppModule {}
autoSchemaFile: true tells NestJS to generate the schema in memory. Without this, your decorators do nothing.
2. Ignoring N+1 queries.
Field resolvers are convenient traps. Always profile your queries with Apollo DevTools or your database logs. If you see duplicate queries, implement DataLoader.
3. Over-nesting your schema.
A User type with posts with comments with author with profile grows unwieldy fast. Keep your graph shallow and predictable. Use aliases and fragments in your client queries to handle complexity.
4. Not using input types for mutations.
Decorating each argument individually makes your resolver unreadable and schema verbose. Always group mutation inputs into an @InputType.
Key Takeaways
- Code-first GraphQL in NestJS means writing TypeScript classes with decorators; the schema generates automatically.
@ObjectTypeand@Fielddefine your types;@Queryand@Mutationdefine your operations.- Field resolvers let you load related data lazily, but watch for N+1 queries—use DataLoader to batch load.
- GraphQL is best for APIs serving multiple, varied frontends. Simple CRUD and public APIs often work better with REST.
- Enable GraphQL in your root module with
autoSchemaFile: true, and always use@InputTypefor complex mutation inputs.






Comments
Loading comments…