NestJS — самый «спринговый» из Node-фреймворков. В его центре DI-контейнер и модули — ровно те же идеи, что в Spring. Если ты только начинаешь с NestJS, понять эти две вещи значит понять, как приложение держится вместе.
Проблема: объекты создают всё сами
Когда пишешь первый сервис, обычно делают так:
class CreateProductHandler {
private repo = new ProductRepository(); // создаём сами
}
Проблема: CreateProductHandler намертво привязан к конкретному ProductRepository. Его не подменить в тестах, не настроить через конфигурацию. Когда сервисов становится больше десяти, паутина new внутри друг друга превращается в источник сложно отслеживаемых ошибок.
Решение — передавать зависимости снаружи, а не создавать внутри. Именно это делает DI-контейнер NestJS.
Провайдеры и DI-контейнер
Провайдер — это класс с декоратором @Injectable(). Он говорит контейнеру: «этим объектом управляй ты». Контейнер сам создаёт экземпляр и подставляет его туда, где он нужен.
import { Injectable } from '@nestjs/common';
@Injectable()
export class ProductRepository {
async find(id: number): Promise<Product | null> { /* ... */ }
}
@Injectable()
export class CreateProductHandler {
constructor(private readonly repo: ProductRepository) {}
async handle(command: CreateProductCommand): Promise<Product> { /* ... */ }
}
CreateProductHandler не создаёт репозиторий сам — он его получает. Контейнер видит тип ProductRepository в конструкторе и подставляет готовый экземпляр. Зависимости объявляются просто: пишешь тип в параметре конструктора — контейнер разберётся.
Чтобы TypeScript-типы были видны в рантайме, NestJS читает метаданные через
reflect-metadata. Это работает автоматически при включённомemitDecoratorMetadata: trueвtsconfig.json— стандартный шаблон NestJS уже содержит эту настройку.
Модуль как граница
Провайдеры не висят в воздухе — они принадлежат модулю. Модуль (@Module) объявляет, что в него входит и что он отдаёт наружу.
import { Module } from '@nestjs/common';
@Module({
controllers: [ProductController],
providers: [CreateProductHandler, ProductRepository],
exports: [ProductRepository],
})
export class ProductModule {}
Четыре поля:
| Поле | Что означает |
|---|---|
providers | что доступно внутри модуля |
controllers | HTTP-контроллеры модуля |
imports | другие модули, чьи экспорты нужны |
exports | что этот модуль отдаёт тем, кто его импортирует |
ProductModule не видит внутренностей OrderModule, пока тот явно что-то не экспортировал через exports. Это граница: снаружи — только то, что разрешено.
Всё приложение собирается в корневом AppModule, который импортирует все остальные модули:
@Module({
imports: [ProductModule, OrderModule, ConfigModule.forRoot()],
})
export class AppModule {}
Scopes — сколько живёт провайдер
По умолчанию провайдер — одиночка (Scope.DEFAULT): один экземпляр на всё приложение. Это правильный выбор почти всегда: провайдер без внутреннего состояния можно безопасно переиспользовать во всех запросах.
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestContext { /* данные конкретного запроса */ }
Два дополнительных scope:
Scope.REQUEST— новый экземпляр на каждый HTTP-запрос. Нужен, когда провайдер держит данные конкретного запроса (например, текущего пользователя). Имеет цену: «заражает» зависящие от него провайдеры тем же scope, поэтому берут его только по необходимости.Scope.TRANSIENT— новый экземпляр на каждое внедрение. Нужен редко.
Динамические модули
Когда модуль нужно настраивать при подключении — передать строку подключения, ключ API или другие параметры — используют динамический модуль. Это статический метод, который возвращает описание модуля.
import { DynamicModule, Module } from '@nestjs/common';
@Module({})
export class StorageModule {
static forRoot(options: StorageOptions): DynamicModule {
return {
module: StorageModule,
providers: [
{ provide: STORAGE_OPTIONS, useValue: options },
StorageService,
],
exports: [StorageService],
};
}
}
// использование:
@Module({
imports: [StorageModule.forRoot({ bucket: 'my-bucket' })],
})
export class AppModule {}
Это шаблон forRoot/forFeature, который встречается у TypeOrmModule, ConfigModule и других популярных модулей NestJS. forRoot — для глобальной настройки один раз, forFeature — для подключения в конкретном модуле с уточнёнными параметрами.
Коротко
- Провайдер — класс с
@Injectable(), которым управляет контейнер. Зависимости объявляются типами в конструкторе. - Модуль — граница видимости: провайдеры доступны только внутри своего модуля, наружу — только через
exports. - Корневой
AppModuleсобирает все остальные модули вместе. - Scope по умолчанию — одиночка на всё приложение;
REQUESTберут только когда нужны данные конкретного запроса. - Динамический модуль — статический метод
forRoot/forFeatureдля настраиваемых модулей.
Что почитать дальше
- Контроллеры и роутинг — как обрабатываются HTTP-запросы.
- DI/IoC и lifecycle в Spring — те же идеи в Java-экосистеме, для сравнения.