Первый Nest: где в нём ваш контейнер, ваш роутер и ваш конвейер

Nest≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Главу назад ваш каркас был набором узлов, а теперь это работающее приложение: контейнер создаёт объекты по списку зависимостей, маршрутизатор выбирает обработчик по адресу, страж и труба стоят до него, перехватчик и фильтр — вокруг. Сегодня рядом с этим каркасом впервые открывается настоящий Nest 12 — и ничего неизвестного в нём не окажется. Те же узлы, те же места в порядке, другие имена. Эта статья не учит Nest заново: она ставит две колонки рядом и показывает, где в чужом фреймворке лежит ваш собственный код. А заодно честно называет места, где настоящий отвечает не так, как ваш, — их ровно четыре, и каждое из них вы сейчас увидите в выводе.

Разделы темы:·

Настоящий Nest начинается не с кода, а с команды: npx @nestjs/cli@12 new notes-api. Она разложит проект и предложит поставить пакеты. Прогон ниже делает ровно это — тем же CLI 12.0.1, но с пропущенной установкой: сейчас интересен не npm install, а то, что окажется в папке и что о проекте решили за вас.

прогон node 26.5.0 · 22 сентября 2026
что раскладывает nest new 12 и что он пишет в package.json и tsconfig
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';

// У читателя это одна команда: npx @nestjs/cli@12 new notes-api. Здесь CLI берётся
// из носителя пакетов прогона, а установку пропускаем — интересен сам проект
const cli = createRequire(import.meta.url).resolve('@nestjs/cli/bin/nest.js');
const { stdout } = spawnSync(
  process.execPath,
  [cli, 'new', 'notes-api', '--skip-install', '--skip-git', '--package-manager', 'npm'],
  { encoding: 'utf8' },
);
// из вывода оставляем только список созданного: дальше идёт просьба о пожертвовании
for (const line of stdout.split('\n')) if (line.startsWith('CREATE')) console.log(line);

const pkg = JSON.parse(readFileSync('notes-api/package.json', 'utf8'));
console.log(`type: ${pkg.type}`);
console.log(`start: ${pkg.scripts.start} | test: ${pkg.scripts.test} | lint: ${pkg.scripts.lint}`);
console.log(`dependencies: ${Object.keys(pkg.dependencies).join(', ')}`);

const tsconfig = JSON.parse(readFileSync('notes-api/tsconfig.json', 'utf8')).compilerOptions;
const flags = ['module', 'target', 'experimentalDecorators', 'emitDecoratorMetadata'];
console.log(`tsconfig: ${flags.map((flag) => `${flag}: ${tsconfig[flag]}`).join(', ')}`);
node scaffold.mjs

Пятнадцать файлов, и приложения среди них четыре: модуль, контроллер, сервис и точка входа. Остальное — инструменты: vitest.config.ts вместо конфигурации jest, .oxlintrc.json вместо eslint, tsconfig.build.json для выпуска. Стартер Nest 12 сменил и то и другое: test зовёт vitest run, lint — oxlint --type-aware. К тому, как устроен такой раннер изнутри, вы вернётесь, когда будете писать свой.

Три строки вывода важнее остальных. type: module — пакеты Nest 12 сами собраны как ES-модули, и стартер поэтому тоже ES-модуль: это то самое поле type из разговора о package.json, а импорты внутри проекта из-за него пишутся с расширением .js. В зависимостях стоит @nestjs/platform-express — платформа по умолчанию; в прогоне следующего раздела вместо неё Fastify, и что такое «платформа» у Nest, разбирает отдельная статья про адаптер. А в tsconfig — experimentalDecorators: true и emitDecoratorMetadata: true: Nest 12 остался на той системе декораторов, от которой вы отказались в пользу стандартной, и почему он не может её сменить — сюжет целой статьи.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вот и вся точка входа. NestFactory.create(AppModule) — это ваш createApp(AppModule): обойти модули, создать контроллеры через контейнер, записать маршруты. app.listen — то, чем у вас заканчивался прогон на настоящем сокете. Команда nest start из scripts сначала собирает проект тем же tsc, что и nest build, — с проверкой типов и выпуском метаданных, — а потом запускает выпуск.

Сборка стартера не прячет компилятор: nest start — это tsc, а потом node dist. Всё, что вы знаете про tsc, здесь работает.

Положим в проект тот самый контроллер заметок, с которым вы работали всю главу: сервис хранит заметки, контроллер отдаёт одну по номеру и заводит новую. Запросы пойдут не через сокет, а через app.inject — у адаптера Fastify это вызов обработчика «как будто по сети», без порта и без ожидания. Вашему каркасу вы писали такой же inject сами.

Прежде чем разворачивать вывод, ответьте на пять вопросов. Что придёт на /notes/99, которой в хранилище нет? Что на /notes/abc? Что на DELETE /notes/1, если метод DELETE в контроллере не объявлен вовсе? Каким кодом ответит POST? И что напечатает первая строка — список зависимостей контроллера, которого в коде нет?

прогон node 26.5.0 · 22 сентября 2026
настоящий Nest 12 на Fastify отвечает на пять запросов без порта и сокета
import 'reflect-metadata';
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { NotesController } from './notes.controller.js';
import { NotesService } from './notes.service.js';

@Module({ controllers: [NotesController], providers: [NotesService] })
class AppModule {}

// список зависимостей контроллера, который вы писали руками, здесь уже готов
console.log('design:paramtypes', Reflect.getMetadata('design:paramtypes', NotesController));

// то же, что ваш createApp: контейнер, обход модулей, маршруты — в роутер платформы
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), { logger: false });
await app.init();

for (const request of [
  { method: 'GET', url: '/notes/1' },
  { method: 'GET', url: '/notes/99' },
  { method: 'GET', url: '/notes/abc' },
  { method: 'DELETE', url: '/notes/1' },
] as const) {
  const response = await app.inject(request);
  console.log(`${request.method} ${request.url} → ${response.statusCode} ${response.body}`);
}

const created = await app.inject({ method: 'POST', url: '/notes', payload: { title: 'молоко' } });
console.log(`POST /notes → ${created.statusCode} ${created.body}`);
// заметку добавил контроллер, а спрашиваем мы сервис, взятый из приложения: объект один
console.log('заметок у сервиса из контейнера:', app.get(NotesService).count());
console.log('контроллер тоже лежит в контейнере:', app.get(NotesController) instanceof NotesController);
await app.close();
nest start

Первая строка — главная. design:paramtypes у NotesController — это [ [class NotesService] ]: список зависимостей, который у вас писался руками в @Controller("/notes", { deps: [NotesService] }), здесь уже лежит в метаданных, и написал его компилятор. Больше ничего нового в контроллере нет: @Controller, @Get, конструктор с зависимостью — ваши декораторы, только путь принято писать без ведущей косой черты.

Дальше ответы. 200 на заметку, которая есть. 404 на ту, которой нет, — и в теле рядом с message и statusCode стоит errorCode: "NOT_FOUND": в 12-й версии у исключений появилось поле под доменный код, и он доезжает до клиента без всякого своего фильтра. 400 на /notes/abc — это ParseIntPipe, отказавший до обработчика: ровно то, что делала ваша труба.

А вот два ответа, которых ваш каркас не даёт. DELETE /notes/1 — 404 Cannot DELETE /notes/1, а не 405 со списком разрешённых методов: маршрут не нашёлся, и Nest не различает «нет адреса» и «не тот способ». И POST /notes — 201, а не 200: код успеха выбирает не обработчик, а сам фреймворк, и для создания он по умолчанию 201.

Последние две строки — про контейнер. Заметку добавил контроллер, а спросили мы app.get(NotesService) — и заметок у него две: контроллеру достался тот же объект, что лежит в контейнере. Сам контроллер тоже в контейнере лежит, и это первое расхождение в устройстве: у вас контроллер в контейнере не хранится вовсе — сборка создаёт его и забывает.

app.inject — это не тест-утилита, а вход в приложение мимо сети. Запрос доходит до тех же звеньев, что и настоящий.

Теперь те же пять запросов — вашему каркасу. Контроллер приходится переписать в четырёх местах: префикс с ведущей косой чертой, список зависимостей в декораторе, труба над методом вместо параметра и разбор тела руками. Всё остальное переносится буквой в букву.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Совпало главное: тот же контроллер, тот же сервис из контейнера, та же труба до обработчика и тот же перевод отказа в ответ одним местом. Разошлись четыре ответа из пяти — и каждое расхождение объяснимо.

ЗапросВаш каркасNest 12 на Fastify
GET /notes/1200, тело от обработчикато же самое
GET /notes/99404 {"code":"NOT_FOUND"}404 с message, error, statusCode и errorCode
GET /notes/abc400 {"code":"BAD_REQUEST"}400 с текстом трубы: «numeric string is expected»
DELETE /notes/1405: адрес есть, метод не тот404: маршрута нет — и весь разговор
POST /notes200: код успеха один на всё201: у создания свой код по умолчанию

Ваш 405 — не ошибка каркаса, а решение, принятое тогда, когда вы писали маршрутизатор: адрес существует, и молчать об этом нечестно. Nest отдаёт маршруты роутеру платформы, а тот отвечает одним 404 на всё, чего не нашёл. Форма тела у вас строже: наружу едет код, а не текст. А тело запроса в вашем обработчике до сих пор разбирает JSON.parse руками — у Nest это делает @Body(), и следующий раздел как раз о том, откуда он берёт значение.

Четыре расхождения из пяти — это подробности, а не устройство. Устройство совпало полностью.

Контейнер Nest — это ваш App из статьи про разрешение вглубь, с теми же правилами: один экземпляр на модуль, поиск по границам модулей, создание снизу вверх. Разница одна, и она в источнике списка зависимостей. Посмотрите, чего стоит эта разница вашему каркасу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Список в декораторе и параметры конструктора — две записи одного и того же, и связи между ними нет. Забыли обновить список — сборка молча позвала конструктор без аргументов, зависимость оказалась undefined, и узнать об этом можно только на том запросе, который её тронет. Позвал бы BrokenController метод у своего сервиса — получил бы TypeError и 500 на каждый запрос, и ни одна проверка при старте про это не сказала бы.

У Nest такого списка в коде нет вовсе — и забыть его поэтому невозможно. Компилятор с emitDecoratorMetadata записывает типы параметров конструктора сам, а контейнер их читает: это и есть та первая строка прогона. Цена — привязка к старым декораторам и к tsc: следующая статья главы показывает, как то же приложение под tsx молча отвечает 500 на каждый запрос, которому нужен сервис, потому что метаданные никто не выпустил.

Контейнер здесь — само приложение: app.get(NotesService) отдаёт готовый объект, а app.get(NotesController) — и контроллер тоже, потому что контроллеры в Nest регистрируются наравне с провайдерами. У вас наружу не отдаётся ничего: createApp создаёт контроллеры и закрывает контейнер в себе. Это не дыра, а незаконченность — App у вас есть, его просто некому спросить.

Второе: app.get ищет по всему приложению, а не по границам модулей, — границы Nest проверяет при сборке, а не при этом вызове. Третье: у провайдера бывает область, и тогда «один экземпляр» перестаёт быть правдой — про запрос как область речь пойдёт в статье о модулях и областях. Четвёртое: незнакомую зависимость Nest объясняет длинным сообщением, которое вы уже умеете читать по своему контейнеру, — «Nest can't resolve dependencies».

Две записи одного факта рано или поздно разъедутся. Весь смысл emitDecoratorMetadata — оставить одну.

Обработчик вашего каркаса получает один аргумент — call с параметрами пути, строкой запроса и телом. Обработчик Nest получает столько аргументов, сколько написал автор, и каждый со своим происхождением: @Param("id"), @Query("draft"), @Body("title"). Механизм под этим — таблица: у каждого параметра метода записано, откуда брать значение и через что его пропустить.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Таблица из трёх функций — это весь механизм. Второй аргумент Param — труба у параметра: @Param("id", ParseIntPipe) в настоящем Nest работает ровно так же, поэтому в прогоне обработчик получил число, а на abc отказ случился до него. У вас труба ставится над методом и получает call целиком: проверить она может всё, а превратить строку в число для одного параметра — некуда.

Почему список лежит рядом с обработчиком, а не над параметрами? Потому что приписать что-нибудь к параметру нечем: в стандартных декораторах декораторов параметра нет, и @Body() в сигнатуре метода даёт TS1206. Nest стоит именно на них — @Param, @Body, @Query, @Req все до одного декораторы параметров, — и в этом, а не в привычке, причина, по которой он остаётся на старой системе.

Та же таблица кормит не только обработчик. По ней же Nest собирает описание маршрута для OpenAPI: что параметр пути, что тело, какой у них тип — всё это уже записано, и документация выходит из той же таблицы метаданных, которую вы завели на WeakMap.

Один call вместо названных параметров — не упрощение миниатюры, а следствие: приписывать что-либо к параметру стандартным декораторам нечем.

Звенья вы разбирали дважды: страж и труба в одной статье, перехватчик и фильтр в другой, и там же названы их настоящие имена. Здесь остаётся последнее — где они объявляются. В вашем каркасе мест два: аргументы createApp и декораторы над классом или методом. В Nest мест три.

ЗвеноГде объявляется у васГде объявляется в Nest 12
прослойкамассив chain в createAppconfigure(consumer) в классе модуля: consumer.apply(Journal).forRoutes("*")
страж@UseGuards на классе или методето же — плюс app.useGlobalGuards или провайдер APP_GUARD
труба@UsePipes на классе или методето же, плюс вторым аргументом у параметра и плюс app.useGlobalPipes
перехватчик@UseInterceptors и общий уровень в createAppто же — app.useGlobalInterceptors или провайдер APP_INTERCEPTOR
фильтр@UseFilters и общий уровень в createAppто же — app.useGlobalFilters или провайдер APP_FILTER

Разница в третьем столбце — не в устройстве, а в полноте. Общий уровень у вас достался только перехватчику и фильтру, а стражу и трубе его никто не дописал: строчка в сборке та же самая, её просто нет. И второй способ объявить общий уровень — провайдером APP_GUARD вместо вызова app.useGlobalGuards — нужен затем, чтобы страж сам пришёл из контейнера со своими зависимостями: объект, созданный руками в main.ts, внедрить в себя ничего не может.

Одно место в порядке всё-таки расходится. Когда маршрут не нашёлся, ваш каркас бросает HttpError из dispatch, а ловит его встроенный ловец — фильтры до него не добираются вовсе, они стоят вокруг обработчика, которого в этом запросе не было. У Nest наоборот: ненайденный маршрут — это NotFoundException, и она проходит через те же фильтры исключений, что и отказ обработчика. Проверено на Nest 12.0.3: глобальный фильтр с @Catch() получает её с текстом Cannot GET /nope.

Следствие практическое: в Nest один фильтр умеет задать форму ответа вообще для всего, включая 404 от роутера. В вашем каркасе такой фильтр пришлось бы ставить звеном конвейера — оно видит уже собранный ответ и может его переписать; звено, которое правит готовый ответ, вы напишете во второй задаче статьи.

Порядок, в котором звенья выполняются, вы уже собрали своими руками, и в Nest он тот же — вплоть до неочевидного места, где трубы лежат внутри матрёшки перехватчиков. Отдельная статья измеряет этот порядок на настоящем Nest журналом, а не берёт из документации.

Пять видов звеньев — это не пять абстракций ради красоты. У каждого своё место в порядке, и это место вы выбирали сами.

Проверка на понимание — чужой боевой файл. Вот контроллер загрузки фотографий из API самого учебника: он работает на сервере, принимает настоящие файлы и написан задолго до этой главы — ещё для Nest 11, в котором всё перечисленное ниже устроено так же, как в 12.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Читается он теперь целиком. @Controller("upload") — префикс, как у вас. @UseGuards(JwtAuthGuard) над классом — страж на всех методах разом, тот самый уровень класса. @Post("photo") — маршрут над методом. @HttpCode(200) — отмена того самого 201, который Nest ставит созданию по умолчанию; теперь понятно, зачем эта строка вообще нужна. @Req() — параметр, которому досталась сырая заявка Fastify: таблица параметров умеет и так.

Дальше конструктор: @Inject(FILE_STORAGE) — внедрение по токену, а не по классу, потому что FileStorage — интерфейс, а интерфейс ключом контейнера быть не может: в рантайме его нет. Компилятор про этот параметр ничего полезного не напишет, и список приходится задать руками — единственное место, где Nest ведёт себя как ваш каркас. А BadRequestException и PayloadTooLargeException — готовые наследники HttpException, ваш HttpError со статусом внутри. Что здесь происходит с самим файлом — поток, лимит и чужое имя — разбирает статья о загрузке файлов.

Если из статьи нужно унести одну вещь, пусть это будет таблица «что я писал — где это здесь». Она отвечает на вопрос, с которым обычно открывают чужой фреймворк: куда смотреть.

Что вы писали своими рукамиГде это искать в проекте на Nest
createApp(AppModule) в конце примераsrc/main.ts: NestFactory.create(AppModule) — единственный вход
deps: [NotesService] в декораторенигде: типы параметров конструктора пишет tsc в design:paramtypes
chain: [journal] вторым аргументомconfigure(consumer) в классе модуля
global: { interceptors, filters }app.useGlobal… в main.ts или провайдеры APP_INTERCEPTOR и APP_FILTER
@UsePipes над методом ради одного параметравторой аргумент у параметра: @Param("id", ParseIntPipe)
throw new HttpError(404, …)throw new NotFoundException(…, { errorCode })
app.inject(request) вашего каркасаон же у адаптера Fastify; в тестах — Test.createTestingModule

Чего в вашем каркасе нет вовсе — список короткий и честный. Областей: провайдер живёт один на приложение, и «по объекту на запрос» вы не писали. Адаптера: ваш каркас умеет ровно один транспорт, а Nest подставляет под себя Express или Fastify, не трогая контроллеры. Хуков жизненного цикла: onModuleInit и onApplicationShutdown нужны, когда у приложения есть что открывать и закрывать, — и про падение на старте, и про мягкую остановку речь впереди. И тестового модуля, который подменяет провайдера заглушкой: подмена держится на том же контейнере, и это отдельная статья.

Дальше по главе — по одной подробности из этой статьи каждая. Модули и области продолжают ваши границы, процедуры tRPC ставят рядом с контроллером другой транспорт, у которого клиент выводится из типа роутера, а схема на границе в 12-й версии принимается прямо в @Body({ schema }) — правда, с ловушкой, о которой вы уже знаете.

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

  • Проект на Nest создаёт одна команда, и половина того, что она разложит, — инструменты: стартер 12-й версии — это ES-модуль с vitest и oxlint, а nest start — это tsc, а потом node dist.
  • NestFactory.create(AppModule) делает то же, что ваш createApp: обходит модули, создаёт контроллеры через контейнер и отдаёт маршруты роутеру платформы. Контейнером здесь работает само приложение — app.get(Токен) отдаёт готовый объект, в том числе контроллер.
  • Список зависимостей — единственное, что у Nest написано не вами: tsc с emitDecoratorMetadata кладёт типы параметров конструктора в design:paramtypes. Ваш список в декораторе — вторая запись того же факта, и её можно забыть: параметр тихо станет undefined.
  • Обработчик Nest получает названные параметры, а не один call: за каждым стоит запись «откуда взять и через что пропустить». Отсюда же труба у отдельного параметра (@Param("id", ParseIntPipe)) и описание для OpenAPI — и отсюда же зависимость от декораторов параметров, которых в стандартной системе нет.
  • Звенья совпали все пять, включая прослойку; разница только в том, где их объявляют: у Nest к классу и методу добавлен общий уровень — app.useGlobal… или провайдер APP_GUARD и его родня, — а ваш общий уровень достался пока только перехватчику и фильтру.
  • Расхождений в ответах четыре, и все они мелкие: 404 вместо вашего 405, 201 у создания вместо общего 200, тело отказа с message и statusCode вместо голого кода и текст отказавшей трубы в теле 400 вместо вашего BAD_REQUEST.

1/4Настоящий Nest ответил 404 на DELETE /notes/1, хотя адрес /notes/:id у него есть. Почему не 405?

2/4Откуда контейнер Nest берёт список зависимостей конструктора?

3/4В Nest строка abc вместо номера превращается в 400 ещё до обработчика. Где стоит то, что её отвергает?

4/4В вашем каркасе у контроллера забыли обновить deps, а параметр конструктора оставили. Что произойдёт?

Ответ в форме Nest

Ваш каркас отвечает на доменный отказ телом {"code":"NOT_FOUND"}, настоящий Nest — телом { statusCode, message, errorCode }. Клиенту, написанному под Nest, ваш ответ непонятен. Соберите общий уровень фильтров так, чтобы доменный отказ уезжал в форме Nest: статус берётся из таблицы STATUS_OF, errorCode — из кода отказа, message — из текста ошибки. Успешный ответ трогать не нужно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Фильтр — пара «что ловлю — чем отвечаю», и filterFor собирает её из типа исключения и ответа:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ответ становится 404 {"statusCode":404,"message":"NOT_FOUND: заметки 99 нет","errorCode":"NOT_FOUND"}. Текст берётся из message самой ошибки, а DomainError складывает его из кода и подробности — если нужен чистый текст, подробность стоит хранить отдельным полем. В настоящем Nest то же самое делает класс с @Catch(DomainError), поставленный через app.useGlobalFilters, — и в его теле есть ещё поле error с названием статуса.

Обратите внимание, где именно этот фильтр работает: вокруг обработчика. Отказ маршрутизатора (404 и 405) через него не пройдёт — его ловит встроенный ловец каркаса.

Двести один на создание

Nest отвечает на POST кодом 201, ваш каркас — кодом 200: код успеха у него один на все маршруты. Допишите прослойку created так, чтобы успешное создание отвечало 201, а остальные ответы остаются как были. Обработчики и сборку трогать нельзя — только звено конвейера.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Звено видит ответ уже собранным — значит, работа идёт после next():

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверка на 200 обязательна: без неё 201 встал бы и на отказ валидатора, и на 404. В Nest этим занимается не звено, а сам фреймворк: 201 у @Post стоит по умолчанию, а меняет его @HttpCode(200) над методом — ровно та строка, которую вы видели в боевом контроллере загрузки.

Таблица параметров для create

Обработчику create(title, draft) нужны два значения: title из тела запроса, draft — из строки запроса, где draft=1 значит «да». Соберите для него таблицу из тех же функций. Сам обработчик трогать нельзя: он не знает, откуда приезжают его аргументы, — в этом весь смысл.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок в таблице — это порядок параметров, и ничего больше:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Печатается «создана заметка «молоко», черновик — true». Превращение "1" в true — это труба у параметра: в Nest её место занял бы ParseBoolPipe, и отказ на непонятном значении случился бы до обработчика. А связь «первый элемент таблицы — первый параметр» держится только на договорённости: у Nest её держит индекс параметра, записанный декоратором в метаданные метода.