NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Module metadata → tokens → instances
11

Dependency Injection и IoC в NestJS

Запустите настоящий Nest application context и сравните class, value, factory, alias, singleton и request-scoped providers.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 11
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: 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.

Зачем это знатьГлубокое понимание DI отделяет архитектуру от набора декораторов. Оно позволяет проектировать module boundaries, заменять инфраструктуру в тестах, диагностировать «Nest can’t resolve dependencies», управлять lifetime и не превращать приложение в скрытый global service locator.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01MODULE METADATAimports · providers · exports
02DI TOKENSclass · Symbol · string
03NEST IoCgraph · resolve · scopes
04INSTANCESservice · repo · adapters
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Dependency

Объект или значение, без которого consumer не может выполнить свою работу.

02

Dependency Injection

Передача dependencies consumer-у извне вместо их создания внутри класса.

03

Inversion of Control

Архитектурный принцип, при котором framework управляет созданием и вызовом пользовательских компонентов.

04

Provider

Регистрация token → способ получить значение. Provider может возвращать class instance, value, factory result или alias.

05

Injection token

Runtime-идентификатор dependency: class, string или Symbol. TypeScript interface сам token-ом быть не может, потому что стирается при компиляции.

06

Application graph

Граф модулей, providers и зависимостей, который Nest строит при bootstrap.

07

Scope

Lifetime provider: singleton DEFAULT, один instance на request или TRANSIENT instance на consumer.

08

Composition root

Место, где инфраструктурные реализации связываются с application contracts; в Nest эту роль выполняет module graph.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Nest читает Module metadata

    imports определяют доступные модули, providers — локальные регистрации, exports — публичный API модуля.

  2. 02
    Строится граф tokens

    Каждая constructor dependency превращается в запрос runtime token.

  3. 03
    Container ищет provider

    Сначала в текущем module context, затем среди exports импортированных модулей.

  4. 04
    Разрешаются зависимости provider-а

    Factory или constructor может сам зависеть от других tokens; контейнер рекурсивно строит порядок.

  5. 05
    Создаётся или возвращается instance

    DEFAULT кэшируется на lifecycle приложения, REQUEST — на ContextId запроса, TRANSIENT — на consumer.

  6. 06
    Lifecycle закрывается

    При app.close Nest вызывает соответствующие lifecycle hooks управляемых providers.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

DI и IoC — не полные синонимы

DI является техникой реализации IoC. Framework также инвертирует контроль над request pipeline, lifecycle hooks и route dispatch.

02

Module — граница видимости

Provider не становится глобальным только из-за @Injectable. Его нужно зарегистрировать, экспортировать из owner module и импортировать там, где он требуется.

03

Интерфейс исчезает в runtime

TypeScript interface полезен для типов, но Nest нужен существующий runtime token. Для ports обычно используют Symbol или abstract class.

04

REQUEST scope распространяется вверх

Если singleton controller зависит от request-scoped service, controller тоже должен создаваться на запрос. Это увеличивает allocations и latency.

05

Circular dependency — сигнал дизайна

forwardRef может разблокировать контейнер, но сначала ищите неверное направление зависимости или недостающий третий orchestration service.

06

Container не должен стать service locator

application.get и ModuleRef полезны в composition/lifecycle edge cases. В обычном business code явный constructor лучше показывает контракт.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
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 {}
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/nest-lab.js
NestJS runtime358 строк
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-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

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

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

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

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

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, возможно, слишком широкий.
07 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

@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, но не устраняет сильную связанность и неопределённый порядок создания.

08 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Чем IoC шире Dependency Injection?
  2. Как Nest найдёт provider из другого feature module?
  3. Почему interface не работает как injection token после компиляции?
  4. Когда useExisting отличается от useClass?
  5. Как request-scoped provider способен незаметно изменить lifetime controller?
  6. Что вы сначала попробуете вместо forwardRef при цикле UsersService ↔ OrdersService?