Skip to main content

Як побудувати 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 підходить, коли схема - це контракт між командами, або коли один пакет схеми використовується в кількох сервісах.

Налаштування

bash
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql
typescript
// 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, тому їх передають явно першим аргументом.

typescript
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() до згенерованих полів.

typescript
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 не знає, до якого типу він належить.

typescript
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.

typescript
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-інстанс для передачі подій між мутаціями і підписниками.

typescript
// Увімкнення у модулі 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 запитів до бази - один.

typescript
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 з неправильного пакету.

typescript
// Неправильно - не додає @Field() до згенерованих полів import { PartialType } from '@nestjs/mapped-types'; // Правильно - зберігає @Field() для генерації GraphQL-схеми import { PartialType } from '@nestjs/graphql';

DataLoader без scope: Scope.REQUEST.

typescript
// Неправильно - кешує дані між усіма запитами @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-ендпоінту з двома операціями.

typescript
// 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, що просто працює, від того, що витримує навантаження.

typescript
// 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

typescript
// 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() витягує розкодованого користувача з об'єкта запиту. Мутація виконується тільки якщо токен валідний.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?