Как делать11 октября 2026 г.9 мин чтения
Backend-разработкаШаг 4 из 13

NestJS: модули, контроллеры, сервисы и DTO

Коротко

NestJS — фреймворк для Node.js, который поверх Express задаёт структуру: код делится на модули, HTTP принимают контроллеры, логика живёт в провайдерах-сервисах, а зависимости между ними связывает встроенный контейнер внедрения зависимостей. Входные данные описываются классами-DTO с декораторами class-validator и проверяются ValidationPipe до вызова обработчика. Для проверки всего модуля Nest поднимает приложение в тесте через @nestjs/testing, а запросы шлёт supertest.

Пошаговый урокУровень: СреднийNestJSПрактика: ~1,5–3 ч

После материала вы сможете

  • создать модуль с контроллером и сервисом через Nest CLI и объяснить, кто и когда создаёт экземпляр сервиса
  • описать входные данные классом-DTO и включить ValidationPipe с whitelist и forbidNonWhitelisted
  • выбросить NotFoundException и ConflictException из сервиса и получить правильные коды 404 и 409
  • написать e2e-тест модуля на @nestjs/testing и supertest, который проверяет 201, 400, 404 и 409

Проверено на

NestJS 12.1 (@nestjs/core 12.1.2, @nestjs/cli 12.0.8)TypeScript 6.0.3class-validator 0.15.1, class-transformer 0.5.1Vitest 4.1, supertest 7.3Express 5.2 (@nestjs/platform-express)Node.js 24 LTS (24.18)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Зачем NestJS, если есть Express
  2. 02Проект: Nest CLI и структура
  3. 03Модули, контроллеры, провайдеры и внедрение зависимостей
  4. 04DTO и ValidationPipe
  5. 05Исключения: 404, 409 и единый формат
  6. 06Тесты: unit-тест сервиса и e2e-тест модуля
  7. 07Частые ошибки и ограничения
  8. 08Упражнения

Зачем NestJS, если есть Express

Express даёт маршруты и middleware (промежуточные обработчики запроса) и больше ничего не навязывает. Пока API — пять обработчиков в одном файле, это плюс. Когда ресурсов становится двадцать, а разработчиков трое, каждый раскладывает код по-своему: где-то проверка данных внутри маршрута, где-то в отдельной функции, а подключение к базе импортируется напрямую из любого места. Тестировать такой код трудно — зависимости спрятаны в импортах.

NestJS решает организационную задачу. Под капотом по умолчанию тот же Express (пакет @nestjs/platform-express, можно переключиться на Fastify), но поверх него фреймворк задаёт единые правила: модуль объявляет, из чего состоит часть приложения; контроллер отвечает только за HTTP; сервис — за логику; объекты не создают друг друга сами, а получают зависимости через конструктор от контейнера Nest.

Маршрут

Express
app.post("/leads", handler)
NestJS
Метод класса с декоратором @Post() в @Controller("leads")

Проверка тела запроса

Express
Своя middleware или вызов схемы в обработчике
NestJS
Класс-DTO + ValidationPipe, обработчик получает уже проверенные данные

Связь обработчика и логики

Express
Прямой импорт модуля
NestJS
Внедрение зависимости через конструктор

Ошибки HTTP

Express
Свой обработчик ошибок (err, req, res, next)
NestJS
Встроенные исключения NotFoundException, ConflictException и слой обработки исключений

Тест модуля целиком

Express
supertest на экспортированном app
NestJS
Test.createTestingModule + supertest, с возможностью подменить провайдер

Цена структуры — больше файлов и понятий: декораторы, модули, провайдеры. Для небольшого сервиса из пары маршрутов Express честнее. NestJS окупается там, где у backend несколько доменов, команда и долгий срок жизни.

Проект: Nest CLI и структура

Проект создаёт Nest CLI. По документации NestJS 12 работает на Node.js 20.19+ (или 22.12+ в ветке 22), а генераторам CLI нужен Node.js 22.22.3+, 24.15+ или 26+. Флаги ниже делают создание неинтерактивным: менеджер пакетов npm, без git-репозитория и без модуля наблюдаемости @nestjs/observe.

bash
npx @nestjs/cli@12 new leads-api --package-manager npm --skip-git --no-observe
cd leads-api
npx nest g module leads
npx nest g controller leads
npx nest g service leads
npm install class-validator class-transformer
Новый проект и заготовки модуля, контроллера и сервиса заявок
terminal
$ npx nest g module leads
CREATE src/leads/leads.module.ts (86 bytes)
UPDATE src/app.module.ts (331 bytes)
$ npx nest g controller leads
CREATE src/leads/leads.controller.ts (103 bytes)
CREATE src/leads/leads.controller.spec.ts (506 bytes)
UPDATE src/leads/leads.module.ts (177 bytes)
$ npx nest g service leads
CREATE src/leads/leads.service.ts (93 bytes)
CREATE src/leads/leads.service.spec.ts (474 bytes)
UPDATE src/leads/leads.module.ts (257 bytes)
Реальный вывод генераторов: каждый следующий шаг сам дописывает себя в модуль

Что важно знать о шаблоне NestJS 12:

  • Проект по умолчанию — ESM: в package.json стоит "type": "module". Поэтому относительные импорты пишутся с расширением .js — import { LeadsService } from './leads.service.js', хотя файл называется .ts. Это требование Node.js для ES-модулей, его фиксирует руководство по миграции на v12.
  • Тест-раннер для ESM-проекта — Vitest (для CommonJS — Jest). Скрипты: npm test запускает unit-тесты *.spec.ts, npm run test:e2e — тесты *.e2e-spec.ts из папки test по отдельному конфигу.
  • Линтер — oxlint, форматирование — Prettier. npm run build вызывает nest build и кладёт JavaScript в dist.
  • В tsconfig.json включены experimentalDecorators и emitDecoratorMetadata: на них держатся декораторы и внедрение зависимостей. strictPropertyInitialization выключен, поэтому поля DTO без начального значения компилируются при strict: true.

Сгенерированные app.controller.ts, app.service.ts и их тест с «Hello World!» в этом уроке удалены — корневой модуль только подключает модуль заявок. Шаблонный leads.controller.spec.ts тоже удалён: контроллер получает сервис через конструктор, и пустой тест без провайдера упал бы.

Модули, контроллеры, провайдеры и внедрение зависимостей

Начнём с модели данных. Статусы заявки — массив-константа, тип выводится из него: один источник правды и для TypeScript, и для проверки во время выполнения.

src/leads/lead.ts
export const LEAD_STATUSES = ['new', 'in_progress', 'done'] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];

export interface Lead {
  id: string;
  name: string;
  phone: string;
  message?: string;
  status: LeadStatus;
  createdAt: string;
}
Модель заявки

Сервис — это провайдер: класс с декоратором @Injectable(). Документация NestJS описывает провайдер как объект, который можно внедрить как зависимость. В сервисе живут правила предметной области: нельзя создать вторую открытую заявку с тем же телефоном, нельзя менять статус несуществующей заявки.

src/leads/leads.service.ts
import { randomUUID } from 'node:crypto';
import {
  ConflictException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { CreateLeadDto } from './dto/create-lead.dto.js';
import type { Lead, LeadStatus } from './lead.js';

@Injectable()
export class LeadsService {
  private readonly leads = new Map<string, Lead>();

  create(dto: CreateLeadDto): Lead {
    const open = [...this.leads.values()].find(
      (lead) => lead.phone === dto.phone && lead.status !== 'done',
    );
    if (open) {
      throw new ConflictException(
        'An open lead with this phone already exists',
        {
          errorCode: 'LEAD_DUPLICATE',
        },
      );
    }
    const lead: Lead = {
      id: randomUUID(),
      name: dto.name,
      phone: dto.phone,
      message: dto.message,
      status: 'new',
      createdAt: new Date().toISOString(),
    };
    this.leads.set(lead.id, lead);
    return lead;
  }

  findAll(): Lead[] {
    return [...this.leads.values()];
  }

  findOne(id: string): Lead {
    const lead = this.leads.get(id);
    if (!lead) {
      throw new NotFoundException(`Lead ${id} not found`);
    }
    return lead;
  }

  updateStatus(id: string, status: LeadStatus): Lead {
    const lead = this.findOne(id);
    lead.status = status;
    return lead;
  }

  remove(id: string): void {
    this.findOne(id);
    this.leads.delete(id);
  }
}
Сервис: хранилище в памяти и правила; ошибки — встроенные исключения Nest

Контроллер принимает HTTP и передаёт работу сервису. Префикс @Controller('leads') склеивается с путём метода: @Get(':id') обслуживает GET /leads/:id. Статус ответа по умолчанию — 200, для POST — 201; @HttpCode(204) меняет его для удаления, когда тела у ответа нет.

src/leads/leads.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  Param,
  ParseUUIDPipe,
  Patch,
  Post,
} from '@nestjs/common';
import { CreateLeadDto } from './dto/create-lead.dto.js';
import { UpdateLeadStatusDto } from './dto/update-lead-status.dto.js';
import type { Lead } from './lead.js';
import { LeadsService } from './leads.service.js';

@Controller('leads')
export class LeadsController {
  constructor(private readonly leadsService: LeadsService) {}

  @Post()
  create(@Body() dto: CreateLeadDto): Lead {
    return this.leadsService.create(dto);
  }

  @Get()
  findAll(): Lead[] {
    return this.leadsService.findAll();
  }

  @Get(':id')
  findOne(@Param('id', ParseUUIDPipe) id: string): Lead {
    return this.leadsService.findOne(id);
  }

  @Patch(':id/status')
  updateStatus(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateLeadStatusDto,
  ): Lead {
    return this.leadsService.updateStatus(id, dto.status);
  }

  @Delete(':id')
  @HttpCode(204)
  remove(@Param('id', ParseUUIDPipe) id: string): void {
    this.leadsService.remove(id);
  }
}
Контроллер: только маршруты, параметры и вызов сервиса

Модуль связывает части. В controllers — то, что Nest должен создать и зарегистрировать как маршруты, в providers — то, что контейнер может внедрять внутри модуля. Корневой AppModule импортирует модуль заявок.

src/leads/leads.module.ts
import { Module } from '@nestjs/common';
import { LeadsController } from './leads.controller.js';
import { LeadsService } from './leads.service.js';

@Module({
  controllers: [LeadsController],
  providers: [LeadsService],
})
export class LeadsModule {}
Модуль заявок
src/app.module.ts
import { Module } from '@nestjs/common';
import { LeadsModule } from './leads/leads.module.js';

@Module({
  imports: [LeadsModule],
})
export class AppModule {}
Корневой модуль после удаления шаблонных AppController и AppService
  1. 01HTTP-запрос
  2. 02ValidationPipe
  3. 03LeadsController
  4. 04LeadsService
  5. 05Map в памяти
Путь запроса POST /leads через части модуля

Как работает внедрение. Конструктор constructor(private readonly leadsService: LeadsService) не создаёт сервис, а объявляет, что он нужен. При запуске Nest читает метаданные типов параметров конструктора (их записывает TypeScript благодаря emitDecoratorMetadata), находит LeadsService среди провайдеров модуля, создаёт его и передаёт в контроллер. По документации область жизни по умолчанию — singleton: экземпляр создаётся один раз и общий для всех, кто от него зависит. Поэтому хранилище Map в сервисе живёт столько же, сколько процесс.

Модули инкапсулированы: провайдер виден только внутри своего модуля. Если сервис заявок понадобится, например, модулю уведомлений, его нужно добавить в exports модуля заявок, а модуль заявок — в imports модуля уведомлений. Без этого Nest при запуске сообщит, что не может разрешить зависимость.

DTO и ValidationPipe

DTO (Data Transfer Object) — описание формы данных, которые приходят в запросе. В NestJS DTO делают классами, а не интерфейсами: интерфейсы TypeScript стираются при компиляции, а pipe-объектам Nest нужен тип во время выполнения. Правила проверки задают декораторы из class-validator.

src/leads/dto/create-lead.dto.ts
import {
  IsNotEmpty,
  IsOptional,
  IsString,
  Matches,
  MaxLength,
} from 'class-validator';

export class CreateLeadDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  name: string;

  @Matches(/^\+7\d{10}$/, {
    message: 'phone must be in the format +7XXXXXXXXXX',
  })
  phone: string;

  @IsOptional()
  @IsString()
  @MaxLength(1000)
  message?: string;
}
Тело POST /leads: имя обязательно, телефон в формате +7XXXXXXXXXX, сообщение необязательно
src/leads/dto/update-lead-status.dto.ts
import { IsIn } from 'class-validator';
import { LEAD_STATUSES, type LeadStatus } from '../lead.js';

export class UpdateLeadStatusDto {
  @IsIn(LEAD_STATUSES)
  status: LeadStatus;
}
Тело PATCH /leads/:id/status: только допустимый статус

Pipe (канал преобразования) в Nest выполняется непосредственно перед обработчиком: получает аргументы, проверяет или преобразует их и либо пропускает дальше, либо бросает исключение — тогда обработчик не вызывается. ValidationPipe превращает тело запроса в экземпляр DTO и запускает правила class-validator. ParseUUIDPipe из контроллера работает так же, но для одного параметра пути.

src/setup.ts
import { type INestApplication, ValidationPipe } from '@nestjs/common';

export function configureApp(app: INestApplication): void {
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
    }),
  );
}
Глобальные настройки приложения вынесены в функцию — её вызывают и main.ts, и e2e-тест
src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { configureApp } from './setup.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  configureApp(app);
  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
Точка входа
  • whitelist: true — поля, которых нет в DTO, удаляются из объекта. Без этого клиент мог бы прислать role или status и, если код где-то разворачивает тело целиком, записать их.
  • forbidNonWhitelisted: true — вместо тихого удаления лишнее поле приводит к ответу 400. Клиент сразу узнаёт об опечатке в имени поля.
  • Формат ошибок по умолчанию — errorFormat: 'list', массив строк в message. В NestJS 12 появилась опция 'grouped' — объект, где ключ — путь к полю.
terminal
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 201 Created
{"id":"546c1f10-d53a-4e09-af7a-3c8a8e17cd39","name":"Aida","phone":"+77011234567","status":"new","createdAt":"2026-10-11T23:12:57.267Z"}

$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
    -d '{"name":"","phone":"8701","role":"admin"}'
HTTP/1.1 400 Bad Request
{"message":["property role should not exist","name should not be empty","phone must be in the format +7XXXXXXXXXX"],"error":"Bad Request","statusCode":400}
Реальные ответы запущенного приложения: успех и три нарушения в одном запросе

Если в проекте уже есть схемы Zod — например, из урока про Express, — в NestJS 12 есть StandardSchemaValidationPipe: он проверяет параметры, у которых в декораторе указана схема (@Body({ schema: createLeadSchema })), и пропускает остальные. Оба pipe можно включить одновременно.

Исключения: 404, 409 и единый формат

В Nest есть встроенный слой обработки исключений: если обработчик или сервис бросает HttpException или его наследника, клиент получает ответ с соответствующим кодом и JSON-телом. Любое другое исключение превращается в 500 с телом {"statusCode": 500, "message": "Internal server error"} — детали ошибки наружу не утекают.

BadRequestException

Код
400
Когда бросать
Данные формально неверны, а pipe этого не поймал

UnauthorizedException

Код
401
Когда бросать
Нет или неверна аутентификация

ForbiddenException

Код
403
Когда бросать
Пользователь известен, но прав нет

NotFoundException

Код
404
Когда бросать
Ресурса с таким идентификатором нет

ConflictException

Код
409
Когда бросать
Запрос противоречит текущему состоянию: дубликат, гонка изменений

UnprocessableEntityException

Код
422
Когда бросать
Данные корректны по форме, но не проходят бизнес-проверку — если команда разделяет 400 и 422

Вторым аргументом исключению можно передать errorCode — стабильный машиночитаемый код, который появляется в теле ответа. Текст message для людей может меняться, а клиент (сайт или мобильное приложение) ветвится по errorCode.

terminal
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 409 Conflict
{"message":"An open lead with this phone already exists","error":"Conflict","statusCode":409,"errorCode":"LEAD_DUPLICATE"}

$ curl -i localhost:3000/leads/42
HTTP/1.1 400 Bad Request
{"message":"Validation failed (uuid is expected)","error":"Bad Request","statusCode":400}

$ curl -i localhost:3000/leads/8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b
HTTP/1.1 404 Not Found
{"message":"Lead 8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b not found","error":"Not Found","statusCode":404}
Реальные ответы: дубликат, неверный UUID (отказ ParseUUIDPipe), несуществующая заявка

Формат по умолчанию — statusCode, message, error. Если API уже отдаёт ошибки по стандарту Problem Details (RFC 9457) с типом application/problem+json — как в уроке про Express, — формат меняют фильтром исключений: класс с декоратором @Catch(), реализующий ExceptionFilter, подключается глобально через app.useGlobalFilters(). Такой фильтр — последнее упражнение урока.

Тесты: unit-тест сервиса и e2e-тест модуля

Test.createTestingModule принимает те же метаданные, что @Module(), а compile() асинхронно собирает граф зависимостей. Для unit-теста сервиса достаточно одного провайдера.

src/leads/leads.service.spec.ts
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { LeadsService } from './leads.service.js';

describe('LeadsService', () => {
  let service: LeadsService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [LeadsService],
    }).compile();
    service = moduleRef.get(LeadsService);
  });

  it('creates a lead with status "new"', () => {
    const lead = service.create({ name: 'Aida', phone: '+77011234567' });
    expect(lead.status).toBe('new');
    expect(service.findOne(lead.id)).toEqual(lead);
  });

  it('rejects a second open lead with the same phone', () => {
    service.create({ name: 'Aida', phone: '+77011234567' });
    expect(() =>
      service.create({ name: 'Aida', phone: '+77011234567' }),
    ).toThrow(ConflictException);
  });

  it('allows a new lead once the previous one is done', () => {
    const first = service.create({ name: 'Aida', phone: '+77011234567' });
    service.updateStatus(first.id, 'done');
    const second = service.create({ name: 'Aida', phone: '+77011234567' });
    expect(second.id).not.toBe(first.id);
  });

  it('throws NotFoundException for an unknown id', () => {
    expect(() =>
      service.findOne('8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b'),
    ).toThrow(NotFoundException);
  });
});
Unit-тест правил сервиса без HTTP

E2e-тест (сквозной, end-to-end) поднимает весь AppModule: createNestApplication() создаёт настоящее приложение, init() инициализирует его, а supertest отправляет HTTP-запросы в app.getHttpServer() без открытия порта. После каждого теста app.close() освобождает ресурсы; новое приложение в beforeEach — это и чистое хранилище для каждого теста.

test/leads.e2e-spec.ts
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { type App } from 'supertest/types.js';
import { AppModule } from '../src/app.module.js';
import { configureApp } from '../src/setup.js';

describe('Leads (e2e)', () => {
  let app: INestApplication<App>;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    configureApp(app);
    await app.init();
  });

  afterEach(async () => {
    await app.close();
  });

  it('POST /leads creates a lead (201)', async () => {
    const res = await request(app.getHttpServer())
      .post('/leads')
      .send({ name: 'Aida', phone: '+77011234567' })
      .expect(201);
    expect(res.body).toMatchObject({ name: 'Aida', status: 'new' });
    expect(res.body.id).toEqual(expect.any(String));
  });

  it('POST /leads rejects an invalid body (400)', async () => {
    const res = await request(app.getHttpServer())
      .post('/leads')
      .send({ name: '', phone: '8701', role: 'admin' })
      .expect(400);
    expect(res.body.message).toEqual(
      expect.arrayContaining([
        'property role should not exist',
        'name should not be empty',
        'phone must be in the format +7XXXXXXXXXX',
      ]),
    );
  });

  it('POST /leads returns 409 for a duplicate open lead', async () => {
    const lead = { name: 'Aida', phone: '+77011234567' };
    await request(app.getHttpServer()).post('/leads').send(lead).expect(201);
    const res = await request(app.getHttpServer())
      .post('/leads')
      .send(lead)
      .expect(409);
    expect(res.body.errorCode).toBe('LEAD_DUPLICATE');
  });

  it('GET /leads/:id returns 400 for a non-UUID and 404 for an unknown id', async () => {
    await request(app.getHttpServer()).get('/leads/42').expect(400);
    await request(app.getHttpServer())
      .get('/leads/8f14e45f-ceea-4e7a-9b1f-1c2d3e4f5a6b')
      .expect(404);
  });

  it('PATCH status and DELETE (204)', async () => {
    const { body: lead } = await request(app.getHttpServer())
      .post('/leads')
      .send({ name: 'Aida', phone: '+77011234567' })
      .expect(201);
    await request(app.getHttpServer())
      .patch(`/leads/${lead.id}/status`)
      .send({ status: 'archived' })
      .expect(400);
    const { body: updated } = await request(app.getHttpServer())
      .patch(`/leads/${lead.id}/status`)
      .send({ status: 'in_progress' })
      .expect(200);
    expect(updated.status).toBe('in_progress');
    await request(app.getHttpServer()).delete(`/leads/${lead.id}`).expect(204);
    await request(app.getHttpServer()).get(`/leads/${lead.id}`).expect(404);
  });
});
E2e-тест: 201, 400, 409, 404, смена статуса и удаление
terminal
$ npm test -- --reporter=verbose

 RUN  v4.1.11 …/leads-api

 ✓ src/leads/leads.service.spec.ts > LeadsService > creates a lead with status "new" 408ms
 ✓ src/leads/leads.service.spec.ts > LeadsService > rejects a second open lead with the same phone 6ms
 ✓ src/leads/leads.service.spec.ts > LeadsService > allows a new lead once the previous one is done 5ms
 ✓ src/leads/leads.service.spec.ts > LeadsService > throws NotFoundException for an unknown id 11ms

 Test Files  1 passed (1)
      Tests  4 passed (4)

$ npm run test:e2e -- --reporter=verbose

 RUN  v4.1.11 …/leads-api

 ✓ test/leads.e2e-spec.ts > Leads (e2e) > POST /leads creates a lead (201) 196ms
 ✓ test/leads.e2e-spec.ts > Leads (e2e) > POST /leads rejects an invalid body (400) 17ms
 ✓ test/leads.e2e-spec.ts > Leads (e2e) > POST /leads returns 409 for a duplicate open lead 17ms
 ✓ test/leads.e2e-spec.ts > Leads (e2e) > GET /leads/:id returns 400 for a non-UUID and 404 for an unknown id 16ms
 ✓ test/leads.e2e-spec.ts > Leads (e2e) > PATCH status and DELETE (204) 24ms

 Test Files  1 passed (1)
      Tests  5 passed (5)
Реальный вывод: 4 unit-теста и 5 e2e-тестов прошли

Зачем configureApp(app) в тесте. Тест не запускает main.ts, поэтому всё, что настроено там через app.useGlobalPipes, в тестовом приложении отсутствует. Мы проверили это, закомментировав вызов: приложение принимает невалидные данные.

terminal
$ npm run test:e2e

 × POST /leads rejects an invalid body (400) 14ms
 × PATCH status and DELETE (204) 15ms

 FAIL  test/leads.e2e-spec.ts > Leads (e2e) > POST /leads rejects an invalid body (400)
Error: expected 400 "Bad Request", got 201 "Created"
 FAIL  test/leads.e2e-spec.ts > Leads (e2e) > PATCH status and DELETE (204)
Error: expected 400 "Bad Request", got 200 "OK"

      Tests  2 failed | 3 passed (5)
Реальный вывод без configureApp: валидация в тесте выключена, 2 теста упали

Отсюда правило: глобальные настройки — в одной функции, которую вызывают и main.ts, и тесты. Альтернатива — регистрировать глобальный pipe как провайдер модуля через токен APP_PIPE, тогда он попадает в приложение вместе с модулем.

Частые ошибки и ограничения

При запуске: Nest can't resolve dependencies of the LeadsController (?)

Причина
Сервис не указан в providers модуля или находится в другом модуле без exports/imports
Исправление
Добавить провайдер в свой модуль или экспортировать его и импортировать модуль

Невалидные данные проходят, в ответе 201

Причина
Нет ValidationPipe (часто — только в тесте) или DTO импортирован через import type
Исправление
Общая функция настройки для main.ts и тестов; обычный импорт DTO

При сборке: TS2307: Cannot find module './leads.service'

Причина
Относительный импорт без .js в ESM-проекте (moduleResolution: nodenext)
Исправление
Писать ./leads.service.js

@Param('id') id: number содержит строку

Причина
Параметры пути и строки запроса всегда приходят строками
Исправление
ParseIntPipe / ParseUUIDPipe или transform: true у ValidationPipe

Данные пропадают после перезапуска

Причина
Хранилище — Map в памяти singleton-сервиса
Исправление
Это учебное упрощение; дальше — база данных через репозиторий
  • Каждая область — отдельный модуль; корневой модуль только импортирует их
  • Контроллер не содержит правил: только маршрут, параметры, вызов сервиса
  • Тело и параметры проверяются до обработчика: DTO + ValidationPipe, Parse…Pipe для пути
  • Ошибки — встроенные исключения с правильным кодом, для клиента — стабильный errorCode
  • Глобальные pipe и фильтры подключаются одинаково в main.ts и в e2e-тесте
  • E2e-тест проверяет не только успех, но и 400, 404, 409

Чего в уроке нет и что понадобится в настоящем сервисе: хранилище в базе данных с репозиторием, аутентификация и права (guards), конфигурация через переменные окружения, документация OpenAPI. Каждая из этих тем — отдельный урок направления; структура «модуль → контроллер → сервис» остаётся той же.

Упражнения

Следующий шаг — урок о слоях backend-приложения: в нём хранилище уходит из сервиса в репозиторий, а правила и транзакции остаются в сервисе. Тесты backend с настоящей базой данных — в уроке о тестировании backend.

Как проверено7 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    Проект leads-api: nest new (@nestjs/cli 12.0.8, --package-manager npm --skip-git --no-observe), nest g module/controller/service leads

    реальный вывод генераторов скопирован в статью; scratchpad/kb/verify/nestjs-vvedenie/leads-api

  • запущено, работает

    lead.ts, DTO, leads.service.ts, leads.controller.ts, leads.module.ts, app.module.ts, setup.ts, main.ts

    npm run build (nest build) — без ошибок; tsc --noEmit (strict) — без ошибок; npm run lint (oxlint) — без замечаний; NestJS 12.1.2, TypeScript 6.0.3, class-validator 0.15.1, Node.js 24.18.0

  • запущено, работает

    Ответы curl (201, 400, 409, 400 uuid, 404)

    node dist/main.js на временном порту, реальные ответы скопированы (localhost:3000 в статье вместо тестового порта)

  • запущено, работает

    leads.service.spec.ts (unit) и test/leads.e2e-spec.ts (e2e)

    npm test — 4/4; npm run test:e2e — 5/5 (Vitest 4.1.11, supertest 7.3.1)

  • запущено, работает

    Ловушка без configureApp в e2e и упражнение «repeat» (без forbidNonWhitelisted)

    без configureApp: 2 failed | 3 passed (201 вместо 400, 200 вместо 400); без forbidNonWhitelisted: 1 failed | 4 passed — пропала строка 'property role should not exist'

  • запущено, работает

    Таблица ошибок: UnknownDependencies и импорт без .js

    провайдер убран из модуля → при запуске «Nest can't resolve dependencies of the LeadsController (?)»; импорт без .js → TS2307 при nest build

  • запущено, работает

    Решения упражнений modify (ListLeadsQueryDto + @Query) и apply (ProblemDetailsFilter)

    копия проекта leads-api-ex: test/exercises.e2e-spec.ts 2/2 + прежние 5/5, tsc --noEmit и nest build без ошибок

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.