Зачем NestJS и ментальная модель DI
NestJS добавляет структуру к голому Node-бэкенду: модули, контроллеры, провайдеры — и dependency injection. Ты объявляешь, что нужно классу, в конструкторе; IoC container строит и внедряет это, поэтому проводка централизована, а код остаётся тестируемым.
Через три месяца жизни голого Express-приложения в папке routes/ лежит 40 файлов, и каждый обработчик где-то наверху тянется к глобальному db и к new MailService(). Чтобы протестировать один контроллер, приходится поднимать настоящую базу — потому что контроллер жёстко зашил то, как он получает свои зависимости. Добавить кэширование значит править двенадцать файлов, в каждом из которых через new создавался один и тот же сервис. Формально всё не так уж плохо — просто нет структуры и нет шва, чтобы что-то подменить. NestJS существует, чтобы дать такому приложению хребет.
Что такое Nest и какую проблему он решает
Express и Fastify намеренно минималистичны: они маршрутизируют HTTP-запросы к функциям — и на этом всё. Для сервиса на 200 строк это идеально, для сервиса на 20 000 строк — мучительно. Без соглашения каждая команда изобретает своё — где живёт бизнес-логика, как обработчик получает клиент к базе, как сгруппированы фичи — и ответы расходятся от файла к файлу. Получается тот самый клубок из хука: логика приклеена к глобальным объектам, зависимости создаются через new где придётся, и нет чистого места, чтобы подсунуть фейк в тесте.
NestJS — это opinionated-фреймворк поверх Node (по умолчанию он использует Express, но может работать и на Fastify). Он не заменяет обработку HTTP; он её организует. Он даёт три основных строительных блока и один механизм, связывающий их:
- Модули (modules) — контейнеры в рамках фичи (
@Module({...})), которые группируют связанный код и объявляют, что они отдают наружу и что им нужно. - Контроллеры (controllers) — классы, чьи методы обрабатывают входящие запросы и формируют ответ. В них маршрутизация, а не бизнес-логика.
- Провайдеры (providers) — всё, что можно внедрить: сервисы, репозитории, фабрики, клиенты. Здесь живёт бизнес-логика, помеченная
@Injectable().
Вместе эти три блока дают каждой фиче постоянный адрес: модуль ею владеет, контроллер открывает её по HTTP, а провайдер делает работу. Когда ты добавляешь новую фичу — ты знаешь, куда кладёт каждый кусок, и следующий инженер, открывший проект, тоже это знает.
Механизм — это dependency injection (DI), и именно эту часть стоит усвоить в первый же день: всё остальное в Nest построено на ней.
Ментальная модель DI: объявляй, что нужно, не создавай это сам
Вот вся идея в одном предложении: класс перечисляет то, что ему нужно, в конструкторе, а предоставлять это — задача чего-то другого. Ты перестаёшь писать new для своих зависимостей.
Без DI контроллер сам тянется наружу и создаёт своих соавторов:
// Ручная проводка — контроллер сам решает, КАК он получает зависимости
class UsersController {
private service: UsersService;
constructor() {
const db = new Database(process.env.DB_URL);
this.service = new UsersService(db); // жёстко зашито, нельзя подменить
}
}Теперь контроллер приварен к конкретному UsersService и к настоящему Database. Чтобы протестировать его, придётся дать настоящую базу; чтобы изменить то, как строится UsersService, придётся править каждое место, где его создавали.
С DI в Nest контроллер просто объявляет зависимость как параметр конструктора. Он никогда не говорит, как сервис создаётся:
import { Injectable, Controller, Get, Module } from "@nestjs/common";
@Injectable()
class UsersService {
findAll() {
return [{ id: 1, name: "Ada" }];
}
}
@Controller("users")
class UsersController {
// Объявлено, а не создано. Экземпляр поставляет Nest.
constructor(private readonly users: UsersService) {}
@Get()
list() {
return this.users.findAll();
}
}
@Module({
controllers: [UsersController],
providers: [UsersService], // регистрируем то, что можно внедрять
})
export class UsersModule {}Когда приложение стартует, IoC container (Inversion of Control container, контейнер инверсии управления) читает сигнатуры конструкторов, вычисляет граф зависимостей, создаёт каждый provider один раз и передаёт экземпляры внутрь. «Инверсия управления» — это и есть название для такого переворота: созданием управляет фреймворк, а не твой код. Ты объявил UsersService в providers модуля, поэтому, когда контейнер строит UsersController, он видит, что конструктору нужен UsersService, создаёт (или переиспользует) его и внедряет.
▸Почему это работает
Откуда Nest знает, какой класс внедрить из private readonly users: UsersService? В TypeScript при включённом emitDecoratorMetadata компилятор записывает типы параметров конструктора как метаданные, которые Nest читает в рантайме через reflect-metadata. Поэтому аннотация типа здесь — не просто документация, а injection token. Для зависимостей, которые не являются классом (значение конфига, интерфейс, сторонний клиент), используют custom providers с явным токеном и @Inject() — это подробно разобрано в документации по fundamentals.
Почему это важно: тестирование, проводка, связанность, lifecycle
Выгода от «объявляй, не создавай» проявляется в четырёх конкретных вещах.
Тестируемость. Поскольку контроллер знает лишь, что ему нужно нечто формы UsersService, тест может подсунуть мок. Тестовый модуль Nest позволяет переопределить provider, чтобы прогнать потребителя с фейком — без настоящей базы, без сети. Тот шов, которого не хватало в хуке, теперь встроен.
const moduleRef = await Test.createTestingModule({
controllers: [UsersController],
providers: [UsersService],
})
.overrideProvider(UsersService)
.useValue({ findAll: () => [{ id: 1, name: "Mock" }] }) // подставляем фейк
.compile();Единый источник проводки. Создание описано один раз, в метаданных модуля, а не разбросано по каждому потребителю. Добавить зависимость в UsersService — это правка одной строки в его конструкторе; никто из тех, кто использует сервис, не меняется.
Слабая связанность (loose coupling). Потребители зависят от того, что provider делает, а не от того, как он собран. Это делает сервисы композируемыми и заменяемыми — кэширование, логирование, другая реализация за тем же токеном — без правки мест вызова.
Управление lifecycle. Контейнер владеет временем жизни каждого provider’а. По умолчанию provider — синглтон: создаётся один раз и разделяется, что и нужно для stateless-сервиса или пула соединений. Nest также вызывает lifecycle-хуки (onModuleInit, onApplicationShutdown), чтобы ресурсы запускались и останавливались в порядке зависимостей.
Когда брать Nest — и когда это перебор
Структура Nest — это рычаг на большой, долгоживущей кодовой базе и чистый оверхед на крошечной. Решение — про размер, срок жизни и команду, а не про моду.
| Аспект | Голый Express / Fastify | NestJS |
|---|---|---|
| Структура | Изобретаешь сам; расходится по командам | Модули/контроллеры/провайдеры, навязаны |
| Dependency injection | Ручной new или самописный контейнер | Встроенный IoC container |
| Шов для тестов | Самописные моки, настоящие зависимости лезут внутрь | overrideProvider подменяет фейки |
| Boilerplate | Минимум на старте | Больше заранее (декораторы, модули) |
| Кривая обучения | Низкая | Выше (DI, декораторы, соглашения) |
Структурированный TypeScript-бэкенд, команда, которой нужны общие соглашения, или сервис, который ты будешь поддерживать годами — всё это многократно окупает первоначальную церемонию. Однофайловый скрипт, одна serverless-функция или одноразовый прототип — нет: декораторы и проводка модулей там чистый налог, а голый Express-обработчик или обычная функция выкатятся быстрее. Перед выбором спроси себя: будет ли эта кодовая база понятна другому инженеру через два года? Если да — соглашения Nest отобьют затраты с лихвой.
Команда начинает долгоживущий внутренний API на TypeScript: много эндпоинтов, несколько общих сервисов (auth, db, mailer), 5 инженеров, проект на годы. Выбери основу.
В Nest у контроллера есть constructor(private readonly users: UsersService), и UsersService указан в providers модуля. Откуда берётся экземпляр UsersService?
В чём главная выгода для тестируемости от объявления зависимости в конструкторе вместо new внутри класса?
- 01Объясни ментальную модель DI в Nest и почему она лучше, чем new зависимостей внутри класса.
- 02Когда NestJS — верный выбор, а когда перебор?
Голые Express и Fastify маршрутизируют запросы — и на этом всё, что оставляет растущий бэкенд без общей структуры и без шва для тестирования: бизнес-логика приклеена к глобалам, а зависимости создаются через new где придётся. NestJS встаёт поверх Node (по умолчанию Express, опционально Fastify) и добавляет эту структуру через модули, контроллеры и провайдеры, связанные dependency injection. Ментальная модель DI — её сердце: класс объявляет, что ему нужно, в конструкторе и никогда это не создаёт; IoC container читает типы конструктора, разрешает граф зависимостей, создаёт каждый provider один раз и внедряет экземпляры — инверсия управления означает, что созданием владеет фреймворк, а не твой код. Этот один сдвиг покупает тестируемость (переопредели provider моком, без настоящей базы), единое центральное место для проводки, слабую связанность между потребителями и реализациями и управляемый контейнером lifecycle. Бери Nest на больших, долгоживущих, командных TypeScript-бэкендах, где такая структура окупает свою первоначальную церемонию, и пропускай для крошечных скриптов и одноразовых функций, где обычный обработчик быстрее. Теперь, когда ты встретишь конструктор вида constructor(private readonly users: UsersService), ты сразу увидишь суть: класс объявляет потребность, IoC container её удовлетворяет — никакого new, никаких глобалов, никакой проводки, разбросанной по всей кодовой базе.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.