NestJS Dependency Injection and IoC
Build a real Nest application context and inspect class, value, factory, alias, singleton, and request-scoped providers.
Timeline
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.
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.
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.
Terms used in this experiment
Understand the words first, then the execution order.
Dependency
An object or value a consumer needs to perform its work.
Dependency Injection
Supplying dependencies from outside instead of constructing them inside the class.
Inversion of Control
The broader principle where a framework controls component creation and invocation.
Provider
A token-to-value recipe that can produce a class instance, value, factory result, or alias.
Injection token
A runtime dependency identifier: class, string, or Symbol. A TypeScript interface is erased and cannot be a token by itself.
Application graph
The graph of modules, providers, and dependencies Nest builds during bootstrap.
Scope
Provider lifetime: application singleton, one instance per request, or transient per consumer.
Composition root
The place where infrastructure implementations are bound to application contracts; Nest module metadata fills this role.
What happens step by step
Each step maps to an observable runtime state.
- 01Read module metadata
imports expose modules, providers define local registrations, and exports define the public module API.
- 02Build the token graph
Each constructor dependency becomes a request for a runtime token.
- 03Find a provider
The container searches the current module context and exports of imported modules.
- 04Resolve provider dependencies
Factories and constructors can depend on other tokens, producing a recursive order.
- 05Create or reuse an instance
DEFAULT is cached for the app, REQUEST for a request ContextId, and TRANSIENT for each consumer.
- 06Close the lifecycle
app.close invokes the appropriate lifecycle hooks of managed providers.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
DI and IoC are not exact synonyms
DI is one technique for implementing IoC. Nest also controls request dispatch and lifecycle hooks.
A module is a visibility boundary
@Injectable alone does not make a provider global; registration, exports, and imports determine access.
Interfaces disappear at runtime
Use a class, Symbol, string, or abstract class when Nest needs a runtime token.
REQUEST scope bubbles upward
A controller consuming a request-scoped service also becomes request-scoped, adding allocations and latency.
Circular dependencies are design signals
forwardRef may unblock resolution, but first look for an incorrect dependency direction or missing orchestration service.
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.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
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 {}The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
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.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
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 · 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 · 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 · 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 · 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.
Common misconceptions
The myth is on the left; the accurate model is on the right.
@Injectable makes a service available everywhere.
It attaches metadata; module providers, imports, and exports define visibility.
DI exists only for unit tests.
Test replacement is a benefit; explicit dependencies and controlled lifetimes are the architectural value.
Every request gets new instances of every service.
DEFAULT is an application singleton. REQUEST must be selected explicitly.
A TypeScript interface can be passed directly to @Inject.
The interface does not exist in JavaScript runtime, so Nest needs a real token.
forwardRef fixes any circular architecture.
It fixes graph resolution but does not remove coupling or ambiguous construction order.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- How is IoC broader than Dependency Injection?
- How does Nest find a provider exported by another feature module?
- Why can an interface not be an injection token after compilation?
- When does useExisting differ from useClass?
- How can a request-scoped provider change a controller lifetime?
- What would you try before forwardRef in a UsersService and OrdersService cycle?