NNODE LOOP LABruntime observatoryNNEON · Articles in 90 languages
CONNECTING
v24.18.0linux/x64
Module metadata → tokens → instances
11

NestJS Dependency Injection and IoC

Build a real Nest application context and inspect class, value, factory, alias, singleton, and request-scoped providers.

PROCESS IDcurrent server
UPTIMEsince startup
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Timeline

READY
0 ms
Events will appear hereRun the selected scenario
#TIMESOURCEEVENT
Waiting for an experiment…
CHAPTER 11
DEEP DIVE · FROM BASICS TO CODE

Understanding: NestJS Dependency Injection and IoC

You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.

FIRST, IN PLAIN LANGUAGE

Without a container, a class constructs everything it needs: a repository, logger, and configuration. With a container, the class declares dependencies while Nest assembles the object graph. Think of a workshop where tools are requested by tokens and a central catalog decides the implementation, lifetime, and construction method.

TECHNICAL FOUNDATION

Dependency Injection supplies dependencies from outside a consumer. Inversion of Control also gives the framework responsibility for component construction, relationships, and lifecycle. Nest builds an application graph from module metadata and provider tokens, resolves constructor dependencies, and caches providers according to scope. The ideas resemble Angular DI and Spring IoC but execute inside the Node.js and TypeScript runtime.

Why it mattersUnderstanding DI beyond decorators helps design module boundaries, replace infrastructure in tests, diagnose resolution errors, manage lifetimes, and avoid turning the container into a hidden global service locator.
WHERE THE WORK RUNS
01MODULE METADATAimports · providers · exports
02DI TOKENSclass · Symbol · string
03NEST IoCgraph · resolve · scopes
04INSTANCESservice · repo · adapters
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Dependency

An object or value a consumer needs to perform its work.

02

Dependency Injection

Supplying dependencies from outside instead of constructing them inside the class.

03

Inversion of Control

The broader principle where a framework controls component creation and invocation.

04

Provider

A token-to-value recipe that can produce a class instance, value, factory result, or alias.

05

Injection token

A runtime dependency identifier: class, string, or Symbol. A TypeScript interface is erased and cannot be a token by itself.

06

Application graph

The graph of modules, providers, and dependencies Nest builds during bootstrap.

07

Scope

Provider lifetime: application singleton, one instance per request, or transient per consumer.

08

Composition root

The place where infrastructure implementations are bound to application contracts; Nest module metadata fills this role.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Read module metadata

    imports expose modules, providers define local registrations, and exports define the public module API.

  2. 02
    Build the token graph

    Each constructor dependency becomes a request for a runtime token.

  3. 03
    Find a provider

    The container searches the current module context and exports of imported modules.

  4. 04
    Resolve provider dependencies

    Factories and constructors can depend on other tokens, producing a recursive order.

  5. 05
    Create or reuse an instance

    DEFAULT is cached for the app, REQUEST for a request ContextId, and TRANSIENT for each consumer.

  6. 06
    Close the lifecycle

    app.close invokes the appropriate lifecycle hooks of managed providers.

03 · CONTEXT

Where the result needs context

These details explain why similar code can sometimes produce a different trace.

01

DI and IoC are not exact synonyms

DI is one technique for implementing IoC. Nest also controls request dispatch and lifecycle hooks.

02

A module is a visibility boundary

@Injectable alone does not make a provider global; registration, exports, and imports determine access.

03

Interfaces disappear at runtime

Use a class, Symbol, string, or abstract class when Nest needs a runtime token.

04

REQUEST scope bubbles upward

A controller consuming a request-scoped service also becomes request-scoped, adding allocations and latency.

05

Circular dependencies are design signals

forwardRef may unblock resolution, but first look for an incorrect dependency direction or missing orchestration service.

06

Do not turn the container into a service locator

ModuleRef and app.get have edge-case uses, while explicit constructor dependencies keep normal business code honest.

01
Theory

First understand which parts of Node participate in execution.

02
Simplified code

Then remove instrumentation and focus on the central mechanism.

03
Runtime code

Finally match the model to the code that produces the live trace.

04 · Simplified code

A minimal model without instrumentation

src/demos.js · educational snippetJavaScript
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 code

The complete code executed by the scenario

This is not an alternative example: these are the functions and files used by the Run button.

ACTUAL SOURCE

The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.

src/nest-lab.js
NestJS runtime358 lines
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 остановлен');
  }
}

The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.

06 · RECIPES

Practical patterns worth keeping nearby

Compare the goal, code, and caveats instead of memorizing syntax without a model.

01

01 · Regular class provider

The consumer declares a dependency and the module registers its implementation.

@Injectable()
export class UsersService {
  constructor(private readonly users: UsersRepository) {}
}

@Module({
  providers: [UsersRepository, UsersService],
  exports: [UsersService],
})
export class UsersModule {}
  • A controller should not construct UsersService with new.
  • UsersRepository stays private until it is exported.
02

02 · Symbol token for an application port

Business logic depends on a contract while the composition root selects an 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,
};
  • The database section can later provide the PostgreSQL adapter.
  • Tests replace the token with an in-memory implementation.
03

03 · Async factory provider

Create a connection or pool before any dependent provider is constructed.

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 awaits the factory Promise before creating consumers.
  • A connection pool is normally a singleton, not request-scoped.
04

04 · Select scope from data lifetime

Use request scope only for genuinely 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 is recommended for most services.
  • A request-scoped dependency changes the entire consumer chain.
05

05 · Override a provider in tests

Exercise a use case without a network or real database.

const module = await Test.createTestingModule({
  imports: [UsersModule],
})
  .overrideProvider(USER_REPOSITORY)
  .useValue(new InMemoryUserRepository())
  .compile();

const service = module.get(UsersService);
  • The test double uses the same token as the production adapter.
  • A difficult override can reveal an overly broad module boundary.
07 · DO NOT CONFUSE

Common misconceptions

The myth is on the left; the accurate model is on the right.

MYTH

@Injectable makes a service available everywhere.

ACTUALLY

It attaches metadata; module providers, imports, and exports define visibility.

MYTH

DI exists only for unit tests.

ACTUALLY

Test replacement is a benefit; explicit dependencies and controlled lifetimes are the architectural value.

MYTH

Every request gets new instances of every service.

ACTUALLY

DEFAULT is an application singleton. REQUEST must be selected explicitly.

MYTH

A TypeScript interface can be passed directly to @Inject.

ACTUALLY

The interface does not exist in JavaScript runtime, so Nest needs a real token.

MYTH

forwardRef fixes any circular architecture.

ACTUALLY

It fixes graph resolution but does not remove coupling or ambiguous construction order.

08 · SELF-CHECK

Explain it in your own words

If you can explain the answer without quoting documentation, your mental model is starting to take shape.

  1. How is IoC broader than Dependency Injection?
  2. How does Nest find a provider exported by another feature module?
  3. Why can an interface not be an injection token after compilation?
  4. When does useExisting differ from useClass?
  5. How can a request-scoped provider change a controller lifetime?
  6. What would you try before forwardRef in a UsersService and OrdersService cycle?