Dependency Injection и IoC в NestJS
Запустите настоящий Nest application context и сравните class, value, factory, alias, singleton и request-scoped providers.
Временная шкала
Разбираем: Dependency Injection и IoC в NestJS
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Без контейнера класс сам создаёт всё, что ему нужно: new Repository(), new Logger(), чтение config. С контейнером класс только объявляет зависимости, а Nest собирает граф объектов. Это похоже на мастерскую: инструмент заказывается по жетону, а конкретный экземпляр, его lifetime и способ создания определяет центральный каталог.
Dependency Injection — способ передать объекту его зависимости извне. Inversion of Control шире: код отдаёт framework контроль над созданием объектов, их связями и lifecycle. Nest строит application graph из metadata модулей и provider tokens, разрешает constructor dependencies и кэширует providers согласно scope. Идеи знакомы по Angular DI и Spring IoC, но исполняются внутри Node.js и TypeScript runtime.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Dependency
Объект или значение, без которого consumer не может выполнить свою работу.
Dependency Injection
Передача dependencies consumer-у извне вместо их создания внутри класса.
Inversion of Control
Архитектурный принцип, при котором framework управляет созданием и вызовом пользовательских компонентов.
Provider
Регистрация token → способ получить значение. Provider может возвращать class instance, value, factory result или alias.
Injection token
Runtime-идентификатор dependency: class, string или Symbol. TypeScript interface сам token-ом быть не может, потому что стирается при компиляции.
Application graph
Граф модулей, providers и зависимостей, который Nest строит при bootstrap.
Scope
Lifetime provider: singleton DEFAULT, один instance на request или TRANSIENT instance на consumer.
Composition root
Место, где инфраструктурные реализации связываются с application contracts; в Nest эту роль выполняет module graph.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Nest читает Module metadata
imports определяют доступные модули, providers — локальные регистрации, exports — публичный API модуля.
- 02Строится граф tokens
Каждая constructor dependency превращается в запрос runtime token.
- 03Container ищет provider
Сначала в текущем module context, затем среди exports импортированных модулей.
- 04Разрешаются зависимости provider-а
Factory или constructor может сам зависеть от других tokens; контейнер рекурсивно строит порядок.
- 05Создаётся или возвращается instance
DEFAULT кэшируется на lifecycle приложения, REQUEST — на ContextId запроса, TRANSIENT — на consumer.
- 06Lifecycle закрывается
При app.close Nest вызывает соответствующие lifecycle hooks управляемых providers.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
DI и IoC — не полные синонимы
DI является техникой реализации IoC. Framework также инвертирует контроль над request pipeline, lifecycle hooks и route dispatch.
Module — граница видимости
Provider не становится глобальным только из-за @Injectable. Его нужно зарегистрировать, экспортировать из owner module и импортировать там, где он требуется.
Интерфейс исчезает в runtime
TypeScript interface полезен для типов, но Nest нужен существующий runtime token. Для ports обычно используют Symbol или abstract class.
REQUEST scope распространяется вверх
Если singleton controller зависит от request-scoped service, controller тоже должен создаваться на запрос. Это увеличивает allocations и latency.
Circular dependency — сигнал дизайна
forwardRef может разблокировать контейнер, но сначала ищите неверное направление зависимости или недостающий третий orchestration service.
Container не должен стать service locator
application.get и ModuleRef полезны в composition/lifecycle edge cases. В обычном business code явный constructor лучше показывает контракт.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
@Injectable()
class UsersService {
constructor(
@Inject(USER_REPOSITORY)
private readonly users: UserRepositoryPort,
) {}
}
@Module({
providers: [
UsersService,
{ provide: USER_REPOSITORY, useClass: SqlUserRepository },
],
exports: [UsersService],
})
class UsersModule {}Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import 'reflect-metadata';
import {
BadRequestException,
Bind,
Catch,
Controller,
Dependencies,
ForbiddenException,
Get,
Injectable,
Module,
Param,
Scope,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ContextIdFactory,
NestFactory,
REQUEST,
} from '@nestjs/core';
import { map } from 'rxjs';
const DI_CONFIG = Symbol('DI_CONFIG');
const AUDIT_LOGGER = Symbol('AUDIT_LOGGER');
const USER_SERVICE_ALIAS = Symbol('USER_SERVICE_ALIAS');
let requestProbeSequence = 0;
class DatabaseConnection {
constructor(config) {
this.config = config;
this.id = `db:${config.database}`;
}
}
Dependencies(DI_CONFIG)(DatabaseConnection);
Injectable()(DatabaseConnection);
class UsersService {
constructor(database, auditLogger) {
this.database = database;
this.auditLogger = auditLogger;
}
describeResolution() {
this.auditLogger.log('UsersService resolved');
return {
databaseId: this.database.id,
environment: this.database.config.environment,
};
}
}
Dependencies(DatabaseConnection, AUDIT_LOGGER)(UsersService);
Injectable()(UsersService);
class RequestScopedProbe {
constructor() {
requestProbeSequence += 1;
this.instanceId = requestProbeSequence;
}
}
Injectable({ scope: Scope.REQUEST })(RequestScopedProbe);
class NestDiLabModule {}
Module({
providers: [
{
provide: DI_CONFIG,
useValue: Object.freeze({
environment: 'learning',
database: 'users',
}),
},
DatabaseConnection,
{
provide: AUDIT_LOGGER,
useFactory: (config) => ({
prefix: config.environment,
messages: [],
log(message) {
this.messages.push(`[${this.prefix}] ${message}`);
},
}),
inject: [DI_CONFIG],
},
UsersService,
{
provide: USER_SERVICE_ALIAS,
useExisting: UsersService,
},
RequestScopedProbe,
],
})(NestDiLabModule);
export async function nestDependencyInjection(emit) {
emit(
'module',
'start',
'Nest читает metadata модуля и строит граф provider tokens',
);
const application = await NestFactory.createApplicationContext(
NestDiLabModule,
{ logger: false },
);
try {
const users = application.get(UsersService);
const usersAgain = application.get(UsersService);
const alias = application.get(USER_SERVICE_ALIAS);
const description = users.describeResolution();
const logger = application.get(AUDIT_LOGGER);
emit(
'container',
'result',
`Constructor injection: ${description.databaseId}, environment=${description.environment}`,
);
emit(
'singleton',
'result',
`DEFAULT scope: повторный get вернул тот же instance = ${
users === usersAgain
}`,
);
emit(
'custom-provider',
'result',
`useExisting alias указывает на тот же UsersService = ${
users === alias
}`,
);
emit(
'factory',
'result',
`useFactory получил DI_CONFIG; audit=${logger.messages.at(-1)}`,
);
const requestA = ContextIdFactory.create();
const requestB = ContextIdFactory.create();
const probeA1 = await application.resolve(RequestScopedProbe, requestA);
const probeA2 = await application.resolve(RequestScopedProbe, requestA);
const probeB = await application.resolve(RequestScopedProbe, requestB);
emit(
'scope',
'result',
`REQUEST scope: context A ${probeA1.instanceId}/${probeA2.instanceId}, context B ${probeB.instanceId}`,
);
emit(
'scope',
'info',
`Внутри одного ContextId instance общий = ${
probeA1 === probeA2
}; между запросами новый = ${probeA1 !== probeB}`,
);
} finally {
await application.close();
emit('lifecycle', 'done', 'Application context закрыт');
}
}
function requestTrace(request) {
request.nestLifecycleTrace ??= [];
return request.nestLifecycleTrace;
}
class LifecycleService {
execute(id, trace) {
trace.push('service');
return { id, entity: `user-${id}` };
}
}
Injectable()(LifecycleService);
class TraceGuard {
canActivate(context) {
const request = context.switchToHttp().getRequest();
const trace = requestTrace(request);
if (request.headers['x-lab-access'] !== 'allow') {
trace.push('guard:deny');
throw new ForbiddenException('x-lab-access must equal allow');
}
trace.push('guard');
return true;
}
}
Injectable()(TraceGuard);
class TraceInterceptor {
intercept(context, next) {
const request = context.switchToHttp().getRequest();
const trace = requestTrace(request);
trace.push('interceptor:before');
return next.handle().pipe(
map((value) => {
trace.push('interceptor:after');
return { ...value, trace: [...trace] };
}),
);
}
}
Injectable()(TraceInterceptor);
class TraceIdPipe {
constructor(request) {
this.request = request;
}
transform(value) {
const trace = requestTrace(this.request);
trace.push('pipe');
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException('id must be an integer');
}
return parsed;
}
}
Dependencies(REQUEST)(TraceIdPipe);
Injectable({ scope: Scope.REQUEST })(TraceIdPipe);
class TraceExceptionFilter {
catch(exception, host) {
const context = host.switchToHttp();
const request = context.getRequest();
const response = context.getResponse();
const status =
typeof exception.getStatus === 'function'
? exception.getStatus()
: 500;
const trace = requestTrace(request);
trace.push(`exception-filter:${status}`);
response.status(status).json({
statusCode: status,
message: exception.message,
trace: [...trace],
});
}
}
Catch()(TraceExceptionFilter);
class LifecycleController {
constructor(request, service) {
this.request = request;
this.service = service;
}
getOne(id) {
const trace = requestTrace(this.request);
trace.push('controller');
return this.service.execute(id, trace);
}
}
Dependencies(REQUEST, LifecycleService)(LifecycleController);
Bind(Param('id', TraceIdPipe))(
LifecycleController.prototype,
'getOne',
Object.getOwnPropertyDescriptor(LifecycleController.prototype, 'getOne'),
);
Get(':id')(
LifecycleController.prototype,
'getOne',
Object.getOwnPropertyDescriptor(LifecycleController.prototype, 'getOne'),
);
UseGuards(TraceGuard)(LifecycleController);
UseInterceptors(TraceInterceptor)(LifecycleController);
UseFilters(TraceExceptionFilter)(LifecycleController);
Controller('nest-lifecycle')(LifecycleController);
class NestLifecycleLabModule {}
Module({
controllers: [LifecycleController],
providers: [
LifecycleService,
TraceGuard,
TraceInterceptor,
TraceIdPipe,
TraceExceptionFilter,
],
})(NestLifecycleLabModule);
async function readJson(response) {
const body = await response.json();
return { status: response.status, body };
}
export async function nestRequestLifecycle(emit) {
const application = await NestFactory.create(NestLifecycleLabModule, {
logger: false,
});
application.use((request, _response, next) => {
requestTrace(request).push('middleware');
next();
});
await application.listen(0, '127.0.0.1');
const address = application.getHttpServer().address();
const origin = `http://127.0.0.1:${address.port}`;
try {
emit(
'http',
'schedule',
'Отправляем успешный запрос в настоящий ephemeral Nest HTTP server',
);
const success = await readJson(
await fetch(`${origin}/nest-lifecycle/42`, {
headers: { 'x-lab-access': 'allow' },
}),
);
emit(
'success-path',
'result',
`HTTP ${success.status}: ${success.body.trace.join(' → ')}`,
);
emit(
'http',
'schedule',
'Отправляем id=not-a-number: Pipe прерывает normal flow',
);
const invalid = await readJson(
await fetch(`${origin}/nest-lifecycle/not-a-number`, {
headers: { 'x-lab-access': 'allow' },
}),
);
emit(
'error-path',
'result',
`HTTP ${invalid.status}: ${invalid.body.trace.join(' → ')}`,
);
emit(
'http',
'schedule',
'Отправляем запрос без доступа: Guard не допускает Interceptor, Pipe и Controller',
);
const denied = await readJson(
await fetch(`${origin}/nest-lifecycle/42`),
);
emit(
'guard-path',
'result',
`HTTP ${denied.status}: ${denied.body.trace.join(' → ')}`,
);
emit(
'comparison',
'info',
'Middleware видит raw HTTP раньше route context; Interceptor знает handler и оборачивает его до/после',
);
} finally {
await application.close();
emit('lifecycle', 'done', 'Ephemeral Nest HTTP server остановлен');
}
}
Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
01 · Обычный class provider
Consumer объявляет dependency, а module регистрирует доступную реализацию.
@Injectable()
export class UsersService {
constructor(private readonly users: UsersRepository) {}
}
@Module({
providers: [UsersRepository, UsersService],
exports: [UsersService],
})
export class UsersModule {}- Controller не должен создавать UsersService через new.
- UsersRepository остаётся внутренним, пока не добавлен в exports.
02 · Symbol token для application port
Business service зависит от контракта, а composition root выбирает adapter.
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
@Injectable()
class UsersService {
constructor(
@Inject(USER_REPOSITORY)
private readonly users: UserRepositoryPort,
) {}
}
const provider = {
provide: USER_REPOSITORY,
useClass: PostgresUserRepository,
};- Реальный Postgres adapter и параметризованный SQL разобраны в следующем блоке баз данных.
- В unit-тесте token заменяется in-memory implementation.
03 · Async factory provider
Один раз создать connection/pool до providers, которые от него зависят.
const databaseProvider = {
provide: DATABASE,
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
const pool = new Pool({
connectionString: config.getOrThrow('DATABASE_URL'),
});
await pool.query('select 1');
return pool;
},
};- Nest дождётся Promise factory до создания consumers.
- Pool обычно singleton, а не request-scoped provider.
04 · Scope выбирается по lifetime данных
Использовать request scope только для действительно request-local state.
@Injectable({ scope: Scope.REQUEST })
class RequestContext {
constructor(@Inject(REQUEST) readonly request: Request) {}
}
@Injectable({ scope: Scope.TRANSIENT })
class OperationTimer {
readonly startedAt = performance.now();
}- DEFAULT рекомендуется для большинства services.
- Request-scoped dependency делает request-scoped всю цепочку consumers.
05 · Override provider в тесте
Проверить use case без сети и настоящей базы данных.
const module = await Test.createTestingModule({
imports: [UsersModule],
})
.overrideProvider(USER_REPOSITORY)
.useValue(new InMemoryUserRepository())
.compile();
const service = module.get(UsersService);- Mock привязан к тому же token, что production adapter.
- Если override сложен, module boundary, возможно, слишком широкий.
Популярные заблуждения
Миф слева, корректная модель справа.
@Injectable автоматически делает service доступным везде.
Decorator добавляет metadata. Доступность определяется providers/imports/exports module graph.
DI нужен только для unit-тестов.
Тестируемость — следствие. Основная ценность — явные зависимости, заменяемая инфраструктура и контролируемый lifecycle.
Каждый request получает новые экземпляры всех services.
DEFAULT scope является singleton на application lifecycle. REQUEST нужно выбирать явно.
TypeScript interface можно напрямую передать в @Inject.
Interface отсутствует в JavaScript runtime; нужен class, Symbol, string или другой существующий token.
forwardRef нормально решает любую циклическую архитектуру.
Он решает техническое разрешение graph, но не устраняет сильную связанность и неопределённый порядок создания.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Чем IoC шире Dependency Injection?
- Как Nest найдёт provider из другого feature module?
- Почему interface не работает как injection token после компиляции?
- Когда useExisting отличается от useClass?
- Как request-scoped provider способен незаметно изменить lifetime controller?
- Что вы сначала попробуете вместо forwardRef при цикле UsersService ↔ OrdersService?