Project: GraphQL Blog API
GraphQL lets clients ask for exactly the data they need in a single round trip, which makes it a natural fit for a blog where posts, authors, and comments are deeply related. In this project you’ll build a code-first GraphQL API in NestJS: object types defined from TypeScript classes, resolvers that fetch and mutate data, DataLoader to kill the N+1 query problem, JWT-backed auth guards, and live subscriptions over WebSockets. The code-first approach keeps your schema and types in one place, so refactors stay type-safe end to end.
Setting up the GraphQL module
NestJS wires GraphQL through @nestjs/graphql and a driver. We’ll use Apollo with autoSchemaFile, which generates the SDL from your decorated classes at startup. Subscriptions need a separate WebSocket transport, enabled with the subscriptions option.
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql
npm install dataloader graphql-subscriptions
// app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import { PostsModule } from './posts/posts.module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
subscriptions: {
'graphql-ws': true,
},
context: ({ req }) => ({ req }),
}),
PostsModule,
],
})
export class AppModule {}
Object types and resolvers
Object types are plain classes annotated with @ObjectType() and @Field(). A resolver class groups the queries, mutations, and field resolvers for a type. Each @Query() or @Mutation() method becomes a root operation in the schema.
// posts/models/post.model.ts
import { ObjectType, Field, ID, Int } from '@nestjs/graphql';
import { Author } from '../../authors/models/author.model';
@ObjectType()
export class Post {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field()
body: string;
@Field(() => Int)
authorId: number;
@Field(() => Author)
author: Author;
}
// posts/posts.resolver.ts
import { Resolver, Query, Args, ID } from '@nestjs/graphql';
import { Post } from './models/post.model';
import { PostsService } from './posts.service';
@Resolver(() => Post)
export class PostsResolver {
constructor(private readonly postsService: PostsService) {}
@Query(() => [Post], { name: 'posts' })
findAll(): Promise<Post[]> {
return this.postsService.findAll();
}
@Query(() => Post, { name: 'post', nullable: true })
findOne(@Args('id', { type: () => ID }) id: string): Promise<Post | null> {
return this.postsService.findOne(id);
}
}
Resolving relations with DataLoader
A naive @ResolveField for author runs one query per post — fetch 50 posts and you fire 51 queries. DataLoader batches all the author IDs requested within a single tick into one call and caches results per request. Register it as a request-scoped provider so the cache never leaks across requests.
// authors/author.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import * as DataLoader from 'dataloader';
import { Author } from './models/author.model';
import { AuthorsService } from './authors.service';
@Injectable({ scope: Scope.REQUEST })
export class AuthorLoader {
constructor(private readonly authorsService: AuthorsService) {}
readonly batchAuthors = new DataLoader<number, Author>(
async (ids: readonly number[]) => {
const authors = await this.authorsService.findByIds([...ids]);
const map = new Map(authors.map((a) => [a.id, a]));
return ids.map((id) => map.get(id) ?? null);
},
);
}
// posts/posts.resolver.ts (field resolver)
import { ResolveField, Parent } from '@nestjs/graphql';
import { AuthorLoader } from '../authors/author.loader';
@ResolveField(() => Author)
author(@Parent() post: Post): Promise<Author> {
return this.authorLoader.batchAuthors.load(post.authorId);
}
The loader MUST be request-scoped (
Scope.REQUEST). A singleton DataLoader would cache stale data and serve one user’s records to another.
Mutations with validation
Inputs use @InputType() classes, and class-validator decorators are enforced by a global ValidationPipe. Resolvers stay thin — they delegate to a service and return the persisted entity.
// posts/dto/create-post.input.ts
import { InputType, Field, Int } from '@nestjs/graphql';
import { IsNotEmpty, MaxLength } from 'class-validator';
@InputType()
export class CreatePostInput {
@Field()
@IsNotEmpty()
@MaxLength(120)
title: string;
@Field()
@IsNotEmpty()
body: string;
@Field(() => Int)
authorId: number;
}
// posts/posts.resolver.ts (mutation)
import { Mutation } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
import { PubSub } from 'graphql-subscriptions';
const pubSub = new PubSub();
@Mutation(() => Post)
@UseGuards(GqlAuthGuard)
async createPost(@Args('input') input: CreatePostInput): Promise<Post> {
const post = await this.postsService.create(input);
await pubSub.publish('postAdded', { postAdded: post });
return post;
}
Auth guards for GraphQL
Standard NestJS guards expect an HTTP request object, but GraphQL hands you a different execution context. Extend AuthGuard and override getRequest to pull the request out of the GraphQL context.
// auth/gql-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
Subscriptions
Subscriptions push data to clients over WebSockets. A @Subscription() resolver returns an async iterator from PubSub, and the mutation above publishes to the matching trigger. Clients connecting via graphql-ws receive every new post in real time.
// posts/posts.resolver.ts (subscription)
import { Subscription } from '@nestjs/graphql';
@Subscription(() => Post, { name: 'postAdded' })
postAdded() {
return pubSub.asyncIterableIterator('postAdded');
}
Output:
{
"data": {
"postAdded": {
"id": "42",
"title": "Shipping a GraphQL API with NestJS",
"author": { "name": "Ada Lovelace" }
}
}
}
Operation reference
| Operation | Type | Auth required | Notes |
|---|---|---|---|
posts | Query | No | Lists all posts |
post(id) | Query | No | Single post, nullable |
createPost | Mutation | Yes (JWT) | Validates input, publishes event |
postAdded | Subscription | No | Streams newly created posts |
Best Practices
- Prefer the code-first approach so your schema is generated from typed classes and stays in sync automatically.
- Always scope DataLoaders to the request to batch efficiently without leaking cached data between users.
- Keep resolvers thin: validation belongs in input DTOs and business logic belongs in services.
- Use
@ResolveFieldfor relations instead of eager joins so clients only pay for the fields they select. - Set query depth and complexity limits in production to block expensive, deeply nested queries.
- Return
nullfrom loader batch functions for missing keys so DataLoader can map results back to the correct order. - Reuse a single
PubSubinstance (or a Redis-backed one for multi-instance deployments) across publish and subscribe sites.