← назад к разделу

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что доступно внутри модуля
controllersHTTP-контроллеры модуля
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-экосистеме, для сравнения.