12k
All articles

Как структурировать крупное Node.js-приложение на NestJS

Организуйте крупное приложение NestJS по функциям: модули, контроллеры, сервисы, проверка DTO, guards, конфигурация и тесты. Упростите зависимости с помощью явных imports и exports.

OpenReplay Team
OpenReplay Team
Как структурировать крупное Node.js-приложение на NestJS

Крупное NestJS-приложение следует организовывать по функциональным областям: у каждого домена (users, orders, billing) должна быть собственная папка модуля с контроллером, сервисом, DTO и тестами, а обмениваться кодом модули должны только через явные exports и imports.

Если вам достался разрастающийся API на Express, симптомы наверняка знакомы. Один файл маршрутов одновременно отвечает за аутентификацию, валидацию и запись в базу данных, а process.env читается в десятке мест. Никто толком не знает, какой хелпер можно безопасно менять.

В этой статье мы создадим небольшой API для пользователей и заказов и разложим каждую его часть по своим местам: модули, контроллеры, провайдеры, валидация, гарды, конфигурация, общий модуль и модульный тест. Если вы ещё выбираете фреймворк, в статье Is There a Rails for JavaScript? сравниваются альтернативы.

Ключевые выводы

  • По умолчанию модуль NestJS держит свои провайдеры закрытыми. Другой модуль может внедрить провайдер, только если модуль-владелец его экспортирует, а модуль-потребитель импортирует этот модуль.
  • При организации по функциональным областям модуль, контроллер, сервис, DTO и тесты каждого домена лежат в одной папке, поэтому изменения в заказах не выходят за пределы src/orders/.
  • При включённой опции whitelist пайп ValidationPipe отбрасывает свойства без декоратора валидации. Если добавить forbidNonWhitelisted, такие запросы будут завершаться ошибкой.
  • Если гард возвращает false, Nest выбрасывает ForbiddenException, поэтому проверки аутентификации живут в одном классе, а не в каждом обработчике.
  • Неразрешённый циклический импорт между модулями останавливает Nest на этапе bootstrap. Вынести общую сущность в отдельный модуль — лучшее решение, чем forwardRef().

Проблема: Express-приложение без правил

Express не задаёт никаких правил размещения кода, поэтому разрастающееся приложение в итоге устроено так, как случайно были написаны первые несколько обработчиков. Большинство проектов начинаются так, как описано в руководстве по настройке Node.js с TypeScript и Express: один app.ts, папка с маршрутами и немного middleware. Через несколько месяцев обработчик выглядит так:

app.post('/orders', (req, res) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
  const { userId, items } = req.body;
  if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
  const user = users.find((u) => u.id === userId);
  if (!user) return res.sendStatus(404);
  orders.push({ id: orders.length + 1, userId, items });
  res.status(201).json(orders.at(-1));
});

В этом обработчике смешаны пять зон ответственности. NestJS отводит каждой из них фиксированное место:

Привычка из ExpressКонструкция NestJS
Файл маршрутовКонтроллер
Модуль-хелпер с бизнес-логикойПровайдер (сервис)
Проверка аутентификации в middleware или обработчикеГард
Чтение process.envConfigService
Ручные проверки req.bodyDTO + пайп валидации

Всё это имеет свою цену. Для API с несколькими маршрутами декораторы, модули и DI-контейнер NestJS — лишняя церемония. Они начинают окупаться, когда несколько человек меняют по одной функциональной области, не вчитываясь в остальную кодовую базу.

Как структурировать крупный проект на NestJS?

Основа поддерживаемой структуры проекта на NestJS — одна папка на функциональную область, у каждой из которых есть собственный модуль. При такой раскладке, чтобы изменить логику заказов, достаточно открыть одну директорию, а не искать нужное в разрозненных деревьях controllers/, services/ и dto/.

src/
├── users/
│   ├── dto/create-user.dto.ts
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.service.spec.ts
│   └── users.module.ts
├── orders/
│   ├── dto/create-order.dto.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.service.spec.ts
│   └── orders.module.ts
├── shared/
│   ├── api-key.guard.ts
│   ├── audit-log.service.ts
│   └── shared.module.ts
├── app.module.ts
└── main.ts

Модули NestJS по умолчанию инкапсулируют свои провайдеры. OrdersService нужен UsersService, поэтому UsersModule должен его экспортировать:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [SharedModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [UsersModule, SharedModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

Граф зависимостей направлен в одну сторону: OrdersModule → UsersModule → SharedModule. Всё, что UsersModule не экспортирует, остаётся внутренним, и именно это позволяет безопасно рефакторить функциональную область.

Что такое контроллеры, провайдеры и внедрение зависимостей в NestJS?

Контроллер сопоставляет HTTP-маршруты с методами. Провайдер — это любой класс с декоратором @Injectable(), который Nest может создать и передать другим классам. Внедрение зависимостей (DI) означает, что класс перечисляет нужные ему зависимости в параметрах конструктора, а фреймворк предоставляет их экземпляры. Тот же механизм позволяет в тесте подставить заглушку вместо реальной зависимости.

// users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface User { id: number; email: string }

@Injectable()
export class UsersService {
  private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];

  findOne(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';

@Injectable()
export class OrdersService {
  private readonly orders: Array<CreateOrderDto & { id: number }> = [];

  constructor(
    private readonly users: UsersService,
    private readonly audit: AuditLogService,
  ) {}

  create(dto: CreateOrderDto) {
    if (!this.users.findOne(dto.userId)) {
      throw new NotFoundException(`User ${dto.userId} not found`);
    }
    const order = { id: this.orders.length + 1, ...dto };
    this.orders.push(order);
    this.audit.record('order.created', order.id);
    return order;
  }
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';

@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.orders.create(dto);
  }
}

Контроллер превращает запрос в единственный вызов сервиса. Правило «пользователь должен существовать» находится в сервисе, где его может переиспользовать любой другой вызывающий код и где до него может добраться тест. Встроенные HTTP-исключения, такие как NotFoundException, преобразуются в соответствующий ответ с ошибкой.

Как работают DTO и пайпы валидации в NestJS?

DTO (data transfer object, объект передачи данных) — это класс, описывающий структуру тела запроса. В связке с пайпом валидации он отклоняет некорректные входные данные ещё до вызова контроллера. Если у вас есть пример полезной нагрузки, конвертер JSON в TypeScript сгенерирует исходный тип, после чего останется добавить декораторы class-validator:

// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';

export class CreateOrderDto {
  @IsInt()
  userId!: number;

  @IsArray()
  @ArrayNotEmpty()
  @IsString({ each: true })
  items!: string[];
}

Зарегистрируйте пайп один раз в main.ts:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );
  await app.listen(3000);
}
bootstrap();

Назначение каждой опции описано в документации по валидации. whitelist отбрасывает свойства без декоратора. forbidNonWhitelisted превращает это молчаливое отбрасывание в ответ с ошибкой. transform преобразует полезную нагрузку в экземпляр класса DTO. ValidationPipe зависит от пакетов class-validator и class-transformer.

В NestJS 12, вышедшем в августе 2026 года, появился второй подход: StandardSchemaValidationPipe, который валидирует схемы Zod, Valibot или ArkType, переданные через @Body({ schema }). Сама по себе опция schema лишь привязывает схему к параметру. Валидация выполняется только после регистрации пайпа, например с помощью app.useGlobalPipes(new StandardSchemaValidationPipe()).

Если пайп не зарегистрирован, об этом сообщит фронтенд. Записи пользовательских сессий (session replay) в интерфейсах, работающих поверх API, показывают рядом ввод пользователя, некорректный запрос и универсальное всплывающее сообщение об ошибке — и сразу становится ясно, что исправлять нужно на границе DTO.

Как гарды и конфигурация решают вопросы аутентификации и переменных окружения?

Гард — это класс с декоратором @Injectable(), реализующий интерфейс CanActivate. Он выполняется перед обработчиком маршрута и решает, продолжится ли обработка запроса. Если гард возвращает false, Nest выбрасывает ForbiddenException (403). В связке с @nestjs/config гард получает секрет из ConfigService, а не из process.env:

// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private readonly config: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
  }
}

Чтение request.headers работает как с адаптером Express, так и с адаптером Fastify. В AppModule проверяйте переменные окружения при старте, чтобы отсутствующий ключ приводил к ошибке на этапе запуска, а не при первом запросе. Начиная с NestJS 12, validationSchema принимает любой объект Standard Schema, например схему Zod. Более ранние версии ожидали схему Joi:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: z.object({ API_KEY: z.string().min(1) }),
    }),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

Конфигурация — единственное место, где глобальный флаг действительно оправдан, ведь её читает почти каждый модуль.

Общий модуль и тестирование с подменой зависимостей

Код, используемый в нескольких функциональных областях, помещается в общий модуль, который его экспортирует. Каждая функциональная область затем импортирует этот модуль, так что все зависимости видны в массиве imports. Пометка модулей декоратором @Global() скрывает эти связи:

// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';

@Module({
  providers: [AuditLogService, ApiKeyGuard],
  exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}

Внедрение зависимостей окупается в тестах. Test.createTestingModule создаёт небольшой модуль, в котором реальные провайдеры заменяются обычными объектами:

// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';

describe('OrdersService', () => {
  it('throws when the user does not exist', async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: UsersService, useValue: { findOne: () => undefined } },
        { provide: AuditLogService, useValue: { record: () => {} } },
      ],
    }).compile();

    const service = moduleRef.get(OrdersService);
    expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
  });
});

Начиная с NestJS 12, nest new настраивает Vitest для ESM-проектов и Jest для проектов на CommonJS. При использовании Vitest импортируйте describe, it и expect из vitest, если глобальные переменные не включены. Настройка интеграционных тестов с реальной базой данных описана в статье о запуске NestJS с Docker Compose.

Какие ошибки типичны для крупных кодовых баз на NestJS?

Большинство структурных проблем в крупных NestJS-приложениях сводятся к трём паттернам:

  • Циклические зависимости между модулями. Если UsersModule и OrdersModule импортируют друг друга, Nest падает на этапе bootstrap, поскольку не может создать экземпляр модуля. forwardRef() разрешает цикл, а перенос общей сущности в собственный модуль устраняет его.
  • Разросшийся AppModule. Корневой модуль должен импортировать функциональные модули и глобальную конфигурацию, а не объявлять десятки контроллеров и провайдеров сам.
  • Логика в контроллерах. Как только контроллер начинает принимать решения, эти правила невозможно переиспользовать или покрыть модульными тестами без HTTP. Сводите каждый метод контроллера к единственному вызову сервиса.

Заключение

Крупное NestJS-приложение остаётся управляемым, когда каждая функциональная область живёт в собственном модуле, модули связаны между собой только явными экспортами, а контроллеры остаются тонкими. Выберите один маршрут в своём текущем API, например POST /orders. Перенесите его бизнес-правила в сервис, проверки тела запроса — в DTO, а аутентификацию — в гард, а затем повторяйте этот подход для одной функциональной области за другой.

Часто задаваемые вопросы

Где размещать логику аутентификации в NestJS: в middleware или в гарде?

Аутентификацию, специфичную для маршрутов, размещайте в гарде. Middleware выполняется до того, как Nest выберет обработчик маршрута, поэтому видит только «сырые» объекты запроса и ответа. Ему недоступны контроллер, обработчик и метаданные декораторов. Гарды выполняются после завершения всех middleware и до любых интерсепторов и пайпов, а также получают ExecutionContext, который предоставляет доступ к обработчику и его классу. Middleware по-прежнему подходит для задач, относящихся ко всем запросам, например для логирования.

Чем вызвана ошибка Nest can't resolve dependencies?

Эта ошибка означает, что Nest не может найти провайдер для одного из параметров конструктора в модуле, указанном в сообщении. Обычно этот провайдер отсутствует в массиве providers модуля и не экспортируется ни одним модулем из его массива imports. Чтобы это исправить, экспортируйте провайдер из модуля-владельца и импортируйте этот модуль. Также проверьте, что ни один класс с декоратором @Injectable() не оказался в массиве imports.

Почему числовая строка в теле запроса не проходит проверку @IsInt даже при включённой опции transform?

При transform: true пайп ValidationPipe превращает JSON-тело в экземпляр класса DTO, но не меняет тип примитивных значений. Поле userId, переданное как '30', остаётся строкой и не проходит @IsInt. Можно отправлять с клиента настоящее число или установить enableImplicitConversion в true внутри transformOptions. У неявного преобразования есть свой подвох: любая непустая строка, включая 'false', превращается в true для булевого поля.

Как валидировать вложенные объекты или массивы объектов в DTO NestJS?

Добавьте к свойству декоратор @ValidateNested() из class-validator и @Type(() => ItemDto) из class-transformer. Без @Type вложенное значение остаётся обычным объектом, и декораторы валидации ItemDto не срабатывают. Для массива объектов используйте @ValidateNested({ each: true }) вместе с тем же декоратором @Type. У вложенного класса DTO должны быть собственные декораторы class-validator, и тогда whitelist применяется и к его свойствам.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.