Як побудувати GraphQL API з NestJS?
GraphQL API з NestJS - це інтеграція на базі Apollo, де TypeScript-декоратори описують схему, резолвери (resolvers) обробляють запити та мутації, а фреймворк сам підключає всі частини.
Теорія
TL;DR
- Встановлюєш
@nestjs/graphql,@nestjs/apollo,@apollo/serverіgraphql - Code-first: пишеш TypeScript-класи з декораторами, схема генерується автоматично при старті
- Schema-first: пишеш SDL-файл вручну, NestJS генерує TypeScript-типи з нього
- Резолвери замінюють контролери -
@Query,@Mutation,@ResolveFieldзамість HTTP-методів - DataLoader вирішує проблему N+1 на вкладених полях; без нього вкладені зв'язки вб'ють продуктивність
Code-first vs schema-first
При code-first підході TypeScript-класи й є схемою. NestJS читає декоратори і записує schema.gql при старті. При schema-first ти сам пишеш SDL-файл, а NestJS генерує з нього відповідні TypeScript-типи.
Більшість команд обирають code-first, бо TypeScript-типи та GraphQL-схема синхронізуються без зайвих кроків. Schema-first підходить, коли схема - це контракт між командами, або коли один пакет схеми використовується в кількох сервісах.
Налаштування
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql// app.module.ts
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql', // сканує декоратори, пише схему при старті
sortSchema: true,
playground: true, // вимикай у продакшені
}),
],
})
export class AppModule {}Якщо файл на диску не потрібен - передай autoSchemaFile: true. Схема генерується в пам'яті.
Об'єктні типи та вхідні типи
Кожен тип, який повертає запит, потребує @ObjectType(). Кожне поле потребує @Field(). NestJS не може вивести з TypeScript масиви чи GraphQL-скаляри на кшталт ID і Int, тому їх передають явно першим аргументом.
import { ObjectType, Field, ID, Int } from '@nestjs/graphql';
@ObjectType()
export class User {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field(() => Int)
age: number;
@Field(() => [Post], { nullable: true })
posts?: Post[];
}Для мутацій використовуй @InputType(). Часткову версію генерує PartialType - але імпортуй його з @nestjs/graphql, а не з @nestjs/mapped-types. Версія з mapped-types не додає @Field() до згенерованих полів.
import { InputType, Field, PartialType } from '@nestjs/graphql';
@InputType()
export class CreateUserInput {
@Field()
name: string;
@Field()
email: string;
@Field(() => Int)
age: number;
}
@InputType()
export class UpdateUserInput extends PartialType(CreateUserInput) {}Резолвери
Резолвер - це місце, де бізнес-логіка підключається до GraphQL. @Resolver(() => User) прив'язує клас до типу User, що важливо для @ResolveField. Без цього field resolver не знає, до якого типу він належить.
import { Resolver, Query, Mutation, Args, ResolveField, Parent } from '@nestjs/graphql';
@Resolver(() => User)
export class UsersResolver {
constructor(
private usersService: UsersService,
private postsService: PostsService,
) {}
@Query(() => [User], { name: 'users' })
findAll() {
return this.usersService.findAll();
}
@Query(() => User, { name: 'user' })
findOne(@Args('id', { type: () => ID }) id: string) {
return this.usersService.findOne(id);
}
@Mutation(() => User)
createUser(@Args('input') input: CreateUserInput) {
return this.usersService.create(input);
}
@Mutation(() => Boolean)
deleteUser(@Args('id', { type: () => ID }) id: string) {
return this.usersService.delete(id);
}
@ResolveField(() => [Post])
posts(@Parent() user: User) {
return this.postsService.findByUserId(user.id); // виконується для кожного User у відповіді
}
}@ResolveField спрацьовує для кожного об'єкта в результаті. Повернеш 50 користувачів - отримаєш 50 окремих звернень до findByUserId. Це і є N+1.
Аутентифікація
Guard-и NestJS працюють у GraphQL з однією зміною: потрібно діставати запит із контексту виконання GraphQL, а не HTTP.
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; // читає HTTP-запит із GraphQL-контексту
}
}
// У резолвері
@Query(() => User)
@UseGuards(GqlAuthGuard)
me(@CurrentUser() user: User) {
return user;
}Без перевизначення getRequest JWT-стратегія читає з HTTP-контексту, який у GraphQL повертає нічого. Guard проходить без помилки, але токен ніколи не перевіряється.
Підписки (subscriptions)
Підписки (subscriptions) використовують WebSocket. Їх потрібно увімкнути в конфігурації модуля і налаштувати PubSub-інстанс для передачі подій між мутаціями і підписниками.
// Увімкнення у модулі
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql',
subscriptions: {
'graphql-ws': true, // сучасний протокол; 'subscriptions-transport-ws' - застарілий
},
})
// У резолвері
@Subscription(() => Message, {
filter: (payload, variables) =>
payload.messageAdded.roomId === variables.roomId,
})
messageAdded(@Args('roomId') roomId: string) {
return pubSub.asyncIterator('messageAdded');
}
@Mutation(() => Message)
async sendMessage(@Args('input') input: SendMessageInput) {
const message = await this.messagesService.create(input);
pubSub.publish('messageAdded', { messageAdded: message });
return message;
}Для продакшену замінюй вбудований PubSub з graphql-subscriptions на Redis-версію (graphql-redis-subscriptions). In-memory варіант не працює при горизонтальному масштабуванні - події з одного сервера не потраплять до клієнтів, підключених до іншого.
DataLoader і проблема N+1
DataLoader збирає всі окремі виклики .load(id) за один тік події і передає їх в одну batch-функцію. Замість 50 запитів до бази - один.
import DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST }) // обов'язково request-scoped, не singleton
export class PostsLoader {
constructor(private postsService: PostsService) {}
readonly batchPosts = new DataLoader<string, Post[]>(
async (userIds: string[]) => {
const posts = await this.postsService.findByUserIds([...userIds]);
// результати мають бути в тому ж порядку, що й вхідні id - це вимога DataLoader
return userIds.map((id) => posts.filter((p) => p.userId === id));
},
);
}
// У резолвері - замінюємо пряме звернення до сервісу на лоадер
@ResolveField(() => [Post])
posts(@Parent() user: User) {
return this.postsLoader.batchPosts.load(user.id);
}scope: Scope.REQUEST - обов'язковий. Singleton DataLoader живе весь час роботи сервера і кешує дані між запитами різних користувачів. Це пряма вразливість: юзер A може побачити дані юзера B.
Типові помилки
PartialType з неправильного пакету.
// Неправильно - не додає @Field() до згенерованих полів
import { PartialType } from '@nestjs/mapped-types';
// Правильно - зберігає @Field() для генерації GraphQL-схеми
import { PartialType } from '@nestjs/graphql';DataLoader без scope: Scope.REQUEST.
// Неправильно - кешує дані між усіма запитами
@Injectable()
export class PostsLoader { ... }
// Правильно - скидає кеш при кожному запиті
@Injectable({ scope: Scope.REQUEST })
export class PostsLoader { ... }Відсутність getRequest у auth guard. Guard компілюється і запускається, але читає не з того контексту. Жодної помилки не виникає. Токен просто не перевіряється.
playground: true у продакшені. Playground відкриває повну схему всім охочим без будь-якого захисту.
In-memory PubSub при горизонтальному масштабуванні. Підписки перестають працювати, щойно з'являється другий інстанс сервера.
Де зустрічається
- Продукти з веб і мобільними клієнтами, які потребують різних наборів даних - найсильніший кейс для GraphQL
@ResolveField+ DataLoader покриває глибоку вкладеність: User -> Posts -> Comments -> Likes- Підписки підходять для чату, сповіщень, live-дашбордів
- Schema-first добре працює в монорепо, де схема ділиться з інструментами генерації коду на фронті (GraphQL Code Generator)
- Параметр
nameу@Query(() => User, { name: 'user' })задає назву операції в схемі - без нього NestJS бере назву методу
Follow-up питання
Q: Яка різниця між @Query і @ResolveField у NestJS?
A: @Query визначає операцію верхнього рівня на типі Query схеми - виконується один раз на запит. @ResolveField визначає резолвер конкретного поля для певного типу - виконується для кожного об'єкта в результаті, саме тому там і виникає N+1.
Q: Чому DataLoader потребує scope: Scope.REQUEST?
A: DataLoader кешує результати за ключем протягом усього свого життя. Singleton-лоадер живе весь час роботи сервера, тому кеш від одного запиту потрапляє в наступний. Request scope скидає кеш з кожним запитом.
Q: Чим відрізняється аутентифікація в GraphQL від REST у NestJS?
A: JWT-стратегія однакова. Різниця - у тому, звідки guard читає запит. У REST - context.switchToHttp().getRequest(). У GraphQL потрібен GqlExecutionContext.create(context).getContext().req, бо контекст виконання має іншу форму всередині Apollo.
Q: Коли обирати schema-first замість code-first?
A: Schema-first підходить, коли GraphQL-схема - це договір між командами, зафіксований до реалізації. Якщо фронтенд і бекенд команди погоджують схему заздалегідь, schema-first робить цей договір явним файлом. Code-first краще, коли одна команда контролює обидві сторони і хоче TypeScript як єдине джерело правди.
Q: Запит повертає 100 користувачів із полем posts. Скільки запитів до бази без DataLoader і зі ним?
A: Без DataLoader: 101 запит (1 для користувачів, 100 для постів). З DataLoader: 2 запити (1 для користувачів, 1 batch-запит з усіма 100 ID). DataLoader збирає всі .load(id) за один тік, потім робить один виклик batch-функції.
Приклади
Базовий резолвер із запитом і мутацією
Мінімальне підключення для отримання робочого GraphQL-ендпоінту з двома операціями.
// user.type.ts
import { ObjectType, Field, ID } from '@nestjs/graphql';
@ObjectType()
export class User {
@Field(() => ID)
id: string;
@Field()
name: string;
@Field()
email: string;
}
// create-user.input.ts
import { InputType, Field } from '@nestjs/graphql';
@InputType()
export class CreateUserInput {
@Field()
name: string;
@Field()
email: string;
}
// users.resolver.ts
@Resolver(() => User)
export class UsersResolver {
constructor(private usersService: UsersService) {}
@Query(() => [User], { name: 'users' })
findAll() {
return this.usersService.findAll();
}
@Mutation(() => User)
createUser(@Args('input') input: CreateUserInput) {
return this.usersService.create(input);
}
}Після старту відкрий http://localhost:3000/graphql і виконай query { users { id name email } }. Схема вже згенерована з декораторів при запуску.
Вкладені поля з DataLoader
Цей патерн відрізняє GraphQL API, що просто працює, від того, що витримує навантаження.
// posts.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class PostsLoader {
constructor(private postsService: PostsService) {}
readonly batchPosts = new DataLoader<string, Post[]>(
async (userIds: string[]) => {
const posts = await this.postsService.findByUserIds([...userIds]);
return userIds.map((id) => posts.filter((p) => p.userId === id));
},
);
}
// users.resolver.ts - додаємо до існуючого резолвера
@ResolveField(() => [Post])
posts(@Parent() user: User) {
return this.postsLoader.batchPosts.load(user.id);
}query { users { name posts { title } } } тепер виконує рівно два запити до бази, незалежно від кількості користувачів. Я бачив, як цей патерн на реальному продукті знижував час відповіді з 400мс до 40мс - це найвища за впливом оптимізація в будь-якому NestJS GraphQL проекті.
Захищена мутація з JWT guard
// gql-auth.guard.ts
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
// users.resolver.ts
@Mutation(() => User)
@UseGuards(GqlAuthGuard)
updateProfile(
@CurrentUser() currentUser: User,
@Args('input') input: UpdateUserInput,
) {
return this.usersService.update(currentUser.id, input);
}Клієнт передає Authorization: Bearer <token> у HTTP-заголовку. Guard читає його через міст GraphQL-контексту, валідує JWT, а @CurrentUser() витягує розкодованого користувача з об'єкта запиту. Мутація виконується тільки якщо токен валідний.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.