open atlas
↑ К треку
NestJS с нуля до senior NEST · 01 · 02

Dependency injection, scope и кастомные провайдеры

Nest регистрирует provider под token и читает типы конструктора, собирая граф один раз при старте. Кастомные провайдеры меняют, как резолвится token; scope решает, сколько живёт инстансов и как далеко расползается REQUEST.

NEST Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Джун меняет @Injectable() на @Injectable({ scope: Scope.REQUEST }) на крошечном сервисе RequestContext, чтобы тот хранил текущего пользователя на запрос. Компилируется, тесты зелёные. Через неделю p99-латентность ползёт вверх по половине API. Причина: каждый контроллер, который инжектит RequestContext, и каждый сервис, который инжектили эти контроллеры, теперь строится заново на каждый запрос. Один декоратор тихо превратил дерево кэшированных синглтонов в per-request-перетряску — REQUEST scope расползается и утянул за собой весь подграф.

Как на самом деле работает резолв

Прежде чем тянуться к кастомному провайдеру или нестандартному scope, нужно понять, как DI-контейнер Nest работает на самом деле — большинство ловушек из хука возникают именно из-за недопонимания порядка поиска и того, что «синглтон» реально означает.

Dependency injection в Nest — не магия, а таблица соответствий, собранная один раз. Каждый provider регистрируется под token, и по умолчанию этот token — сам класс. Когда ты пишешь providers: [CatsService], это сокращение полной формы { provide: CatsService, useClass: CatsService }: token (provide) и то, что его удовлетворяет (useClass), оказываются одним классом.

Резолв идёт от конструктора потребителя назад. Когда Nest нужно построить CatsController, он читает типы параметров его конструктора — constructor(private cats: CatsService) — и для каждого ищет provider, зарегистрированный под этим token в модуле контроллера (и в его импортах). Рекурсивно резолвит зависимости каждой зависимости, строит ориентированный граф, топологически сортирует его и инстанцирует от листьев вверх. Важно: для провайдеров со scope по умолчанию это происходит один раз, при старте; полученные синглтоны кэшируются и раздаются каждому потребителю дальше. Знаменитая ошибка «Nest can’t resolve dependencies of X» — это просто отсутствующий узел: token, который никто не зарегистрировал, или зарегистрировал в модуле, который не импортирован или не экспортирует его.

// providers: [CatsService] — сахар над канонической длинной формой:
{ provide: CatsService, useClass: CatsService }

@Injectable()
export class CatsController {
  // ТИП здесь (CatsService) — это token, который ищет Nest.
  constructor(private readonly cats: CatsService) {}
}

Кастомные провайдеры: меняем то, во что резолвится token

Длинная форма существует, чтобы развязать token и реализацию. Есть три рабочие лошадки плюс алиас:

  • useClass — резолвить token в класс, возможно в другой. { provide: CatsService, useClass: MockCatsService } заставит каждого инжектора CatsService получить мок — подмена невидима для потребителей.
  • useValue — резолвить token в готовое значение: константу, объект конфига, соединение или тестовый дубль. Никакого инстанцирования, Nest просто отдаёт значение.
  • useFactory — резолвить token, запустив функцию при старте. Фабрика может объявить свои зависимости через inject и может быть async (вернуть Promise, который Nest дождётся до готовности графа) — стандартный способ для async-конфига или открытия соединения.
  • useExisting — сделать алиас одного token на другой, чтобы два token делили один инстанс.
// useValue: константа / объект конфига / мок
{ provide: 'CONFIG', useValue: { apiKey: process.env.API_KEY } }

// useFactory: вычисляется при старте, может инжектить других, может быть async
{
  provide: 'DB_CONNECTION',
  useFactory: async (config: ConfigService) => {
    const conn = await createConnection(config.get('DB_URL'));
    return conn;
  },
  inject: [ConfigService], // резолвится и передаётся в аргументы фабрики, по порядку
}

Injection tokens: когда указывать не на что

Класс служит сам себе token, потому что существует в рантайме. А TypeScript-интерфейс — нет: интерфейсы стираются при компиляции, поэтому constructor(private repo: CatsRepository), где CatsRepository — интерфейс, не даёт Nest ничего для поиска. То же с обычным объектом конфига. Фикс — явный injection token: строка или, лучше, Symbol, зарегистрированный как значение provide и подтянутый декоратором @Inject(TOKEN), раз тип сам по себе его не несёт.

export const CATS_REPO = Symbol('CATS_REPO'); // стабильный token, без коллизий имён

@Module({
  providers: [{ provide: CATS_REPO, useClass: PostgresCatsRepository }],
})
export class CatsModule {}

@Injectable()
export class CatsService {
  // @Inject обязателен: тип интерфейса исчезает в рантайме.
  constructor(@Inject(CATS_REPO) private readonly repo: CatsRepository) {}
}
Почему это работает

Почему Symbol, а не строковый token? Строковые token вроде 'CONFIG' живут в плоском глобальном пространстве имён — две библиотеки, обе выбравшие 'CONFIG', тихо столкнутся, и победит вторая регистрация. Symbol('CONFIG') уникален по идентичности, даже если у двух символов одинаковое описание, так что экспорт export const CONFIG = Symbol('CONFIG') и импорт именно этой привязки делают token невозможным затенить случайно. Строки ок для быстрой внутренней проводки приложения; символы — безопаснее для всего, что публикуется или делится между модулями.

Четыре стратегии резолва различаются по нескольким осям — когда брать каждую, может ли она подтягивать других провайдеров и может ли делать async-работу. Вместе они покрывают все потребности в проводке: useClass — для подмены реализаций, useValue — для констант и тестовых дублей, useFactory — для всего, что нужно вычислить или дождаться при старте, useExisting — чтобы избежать случайного дублирования. Без async-поддержки useFactory тебе пришлось бы открывать соединение с базой до готовности графа Nest — именно поэтому это стандартный подход для соединений и конфига.

СтратегияКогда братьИнжектит других?Async?
useClassПодменить реализацию за token (реальная vs мок)Да (через конструктор класса)Нет
useValueКонстанты, объекты конфига, моки, готовые соединенияНет (значение фиксировано)Нет
useFactoryЗначение надо вычислить при старте (по env, условно)Да (через массив inject:)Да (возвращает Promise)
useExistingАлиас одного token на другой, общий инстансН/Д (указывает на существующий provider)Нет

Scope: сколько инстансов и ловушка заражения

Резолв решает, что инжектить; scope решает, сколько инстансов существует и как долго они живут. Их три:

  • Scope.DEFAULT (singleton) — один инстанс на всё приложение, построенный раз при старте и закэшированный. Это норма, и это быстро: никакой per-request-аллокации, граф уже проложен.
  • Scope.REQUEST — новый инстанс на каждый входящий запрос, собирается сборщиком мусора по завершении запроса. Нужен только когда provider обязан хранить настоящее per-request-состояние (текущий пользователь, request-scoped-транзакция). Цена реальна и, хуже того, она расползается: request scope распространяется вверх по цепочке инжектов, так что любой provider или контроллер, инжектящий request-scoped-provider, сам становится request-scoped, и так далее транзитивно. Один REQUEST-provider глубоко в графе может тихо сделать большое поддерево per-request — ровно регрессия латентности из хука.
  • Scope.TRANSIENT — свежий инстанс для каждого потребителя, инжектящего token; transient-инстансы не делятся. Полезно для stateful-хелперов (per-consumer-логгер со своим контекстом), где деление было бы неверным.
// Singleton (неявный default) — самый быстрый, один инстанс на приложение
@Injectable()
export class CatsService {}

// Request-scoped — новый инстанс на запрос; ЗАРАЗЕН вверх по цепочке
@Injectable({ scope: Scope.REQUEST })
export class RequestContext {}

// Transient на кастомном провайдере — новый инстанс на потребителя
{ provide: 'LOGGER', useClass: Logger, scope: Scope.TRANSIENT }

Failure-режимы, с которыми ты реально столкнёшься

Три ломают живые приложения. Circular dependency: модуль A нужен B, а B нужен A, поэтому Nest не может решить, что строить первым, и одна сторона резолвится в undefined. Аварийный люк — forwardRef(() => OtherService) с обеих сторон, но это запашок — чище обычно вынести общую логику в третий provider, от которого зависят оба. «Nest can’t resolve dependencies of X»: token не зарегистрирован, либо живёт в модуле, который не импортирован, либо импортирован, но не export-нут. Случайное заражение request-scope: REQUEST- (или TRANSIENT-) provider просачивается в горячий путь и тихо делает своих потребителей request-scoped, стоя латентности без пользы — проверь scope до релиза.

Викторина

Ты инжектишь интерфейс — constructor(private repo: CatsRepository) — и получаешь 'Nest can't resolve dependencies'. Почему?

Викторина

Листовой сервис помечен @Injectable({ scope: Scope.REQUEST }). Что станет с контроллерами и сервисами, которые его инжектят?

Выбери лучший вариант

Сервису нужно знать аутентифицированного пользователя текущего запроса внутри глубоко вложенной бизнес-логики. Выбери подход.

Вспомните перед уходом
  1. 01
    Пройди по тому, как Nest резолвит зависимости контроллера, и что на самом деле значит ошибка 'Nest can't resolve dependencies of X'.
  2. 02
    Почему добавление scope: Scope.REQUEST к одному маленькому сервису бьёт по латентности всего API, и какая singleton-безопасная альтернатива для per-request-данных?
Итог

DI в Nest — это таблица соответствий, собранная один раз: каждый provider регистрируется под token (по умолчанию — сам класс, ведь providers: [X] разворачивается в { provide: X, useClass: X }), а контейнер резолвит потребителя, читая типы параметров его конструктора как token, рекурсивно строя и топологически сортируя граф зависимостей, затем инстанцируя от листьев при старте и кэшируя синглтоны. Кастомные провайдеры развязывают token и реализацию: useClass подменяет класс за token (реальный vs мок), useValue отдаёт константу или объект конфига, useFactory вычисляет значение при старте и может инжектить других провайдеров через массив inject и даже быть async, а useExisting делает алиас одного token на другой. Поскольку TypeScript-интерфейс стирается в рантайме, инжектить его по типу нельзя — зарегистрируй явный string- или Symbol-token и подтяни его через @Inject(TOKEN). Scope управляет временем жизни инстанса: Scope.DEFAULT — быстрый синглтон на приложение, Scope.TRANSIENT даёт каждому потребителю свой инстанс, а Scope.REQUEST даёт по одному на запрос, но расползается по цепочке, делая каждого потребителя request-scoped — тихая ловушка латентности. Следи за circular dependency (ломай через forwardRef, лучше вынеся общий provider), ошибками отсутствующего token (зарегистрируй, импортируй или экспортируй token) и случайным заражением request-scope в горячих путях. Теперь, когда увидишь тихий рост p99 после небольшого изменения провайдера, — сначала проверяй scope: одна аннотация Scope.REQUEST глубоко в графе способна молча пересобирать поддерево синглтонов на каждый запрос.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.