NestJS Dependency Injection Internals

How Nest's IoC container actually wires the graph, why dynamic modules and request scope bite you at scale, and the custom-decorator habits that kept the codebase testable as the team grew.

It was a Saturday afternoon at the combat-sports tournament platform I CTO’d in London. A live broadcast was running, the public standings page had just frozen mid-event, and honestly my first instinct was to blame Kafka. The standings-projector consumer group was rebalancing every thirty seconds. Ten minutes of side-by-side pod logs later, the real culprit was one pod running a stale image. Different max.poll.interval.ms, different downstream client, different DI graph. Same module file. Same code on main. Different container at runtime.

That’s the reason I care about Nest’s DI internals. Not because DI is fancy. Because the moment your team grows past a handful of engineers, the boring discipline of how providers get registered and resolved is the thing keeping the codebase testable.

This is what I’ve learned running NestJS in production. Hundreds of microservices on the federation platform. A few more since.

The container in 30 seconds

Nest builds a module graph at boot. Each module declares providers, imports, exports. The container walks the graph, instantiates providers in dependency order, and caches them as singletons by default. @Injectable() only marks a class as eligible. The wiring is the metadata on the module.

The piece people miss is tokens. A provider isn’t keyed by the class name. It’s keyed by a token, usually the class itself, optionally a string or a Symbol. When you @Inject('KAFKA_CLIENT') you’re asking the container for whatever was registered under that token. If two modules register the same token with different useFactory results, the consuming module gets whichever one’s in its resolution scope. That’s half the “but this works in dev” bugs I’ve debugged.

import { Module, Injectable, Inject } from '@nestjs/common';

export const KAFKA_CLIENT = Symbol('KAFKA_CLIENT');

@Injectable()
export class StandingsProjector {
  constructor(
    @Inject(KAFKA_CLIENT) private readonly kafka: KafkaClient,
    private readonly repo: StandingsRepository,
  ) {}
}

@Module({
  providers: [
    StandingsProjector,
    StandingsRepository,
    {
      provide: KAFKA_CLIENT,
      useFactory: (config: ConfigService) => createKafkaClient({
        brokers: config.get('KAFKA_BROKERS').split(','),
        maxPollIntervalMs: 300_000,
        sessionTimeoutMs: 30_000,
      }),
      inject: [ConfigService],
    },
  ],
})
export class StandingsModule {}

Symbols over strings. Strings collide silently. Symbols don’t.

Dynamic modules earn their keep

The pattern that holds up at scale is forRootAsync. Static imports are fine when nothing is configurable. The second you need env-driven config, multi-tenant routing, or runtime feature flags, you want a dynamic module returning a DynamicModule shape.

import { DynamicModule, Module, Provider } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';

export interface KafkaModuleOptions {
  brokers: string[];
  clientId: string;
  maxPollIntervalMs?: number;
}

@Module({})
export class KafkaModule {
  static forRootAsync(opts: {
    imports?: any[];
    useFactory: (...args: any[]) => Promise<KafkaModuleOptions> | KafkaModuleOptions;
    inject?: any[];
  }): DynamicModule {
    const optionsProvider: Provider = {
      provide: 'KAFKA_OPTIONS',
      useFactory: opts.useFactory,
      inject: opts.inject ?? [],
    };

    const clientProvider: Provider = {
      provide: KAFKA_CLIENT,
      useFactory: (options: KafkaModuleOptions) => createKafkaClient(options),
      inject: ['KAFKA_OPTIONS'],
    };

    return {
      module: KafkaModule,
      imports: opts.imports ?? [],
      providers: [optionsProvider, clientProvider],
      exports: [KAFKA_CLIENT],
      global: false,
    };
  }
}

Two things teams get wrong here. First, marking everything global: true because it’s convenient. It hides coupling. The moment two modules silently inherit the same client, one of them shouldn’t have. Second, inlining the factory at the call site then duplicating it across feature modules. Centralize the factory. Pass the config in.

Scope management bites back

By default every provider is a singleton. Almost always what you want. But Nest supports Scope.REQUEST and Scope.TRANSIENT, and the moment you sprinkle @Injectable({ scope: Scope.REQUEST }) on a service deep in the graph, every consumer of it, and every consumer of those consumers, becomes request-scoped too. The container instantiates the whole chain per request.

I’ve seen p99 climb from 80 ms to over 400 ms after one well-meaning PR added request scope to a logger to attach a trace id. The logger was used by every controller. Fix was a one-line revert plus a CLS hook for trace propagation.

import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
  constructor(@Inject(REQUEST) private readonly req: Request) {}

  get tenantId(): string {
    const header = this.req.headers['x-tenant-id'];
    if (!header || Array.isArray(header)) {
      throw new Error('missing tenant header');
    }
    return header;
  }

  get traceId(): string | undefined {
    return this.req.headers['x-trace-id'] as string | undefined;
  }
}

Rule I’ve held to since: request scope is fine when the dependency cone is small and the work is genuinely per-request, like reading a tenant from the JWT. It is not fine when the dependency cone is the whole app. Use AsyncLocalStorage (or nestjs-cls) for cross-cutting context. Keep the rest singleton.

Custom decorators kept us testable

The thing that actually saved us back then wasn’t any specific framework feature. It was that we wrote a handful of custom param decorators early, and then stopped passing the raw Request object around. Once a controller method took a CurrentUser instead of digging into req.user, the test setup got dramatically smaller. The handler stopped knowing about HTTP at all.

import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';

export interface AuthenticatedUser {
  id: string;
  email: string;
  roles: ReadonlyArray<string>;
}

export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): AuthenticatedUser => {
    const req = ctx.switchToHttp().getRequest();
    const user = req.user as AuthenticatedUser | undefined;
    if (!user) {
      throw new UnauthorizedException('no authenticated user');
    }
    return user;
  },
);

@Controller('matches')
export class MatchesController {
  constructor(private readonly service: MatchesService) {}

  @Post(':id/result')
  async submitResult(
    @Param('id') matchId: string,
    @CurrentUser() user: AuthenticatedUser,
    @Body() body: SubmitResultDto,
  ) {
    return this.service.submitResult({ matchId, submittedBy: user.id, ...body });
  }
}

In tests you wire the controller without any HTTP layer, pass a plain object as the user, and assert against the service spy. No supertest, no fake Express request. The decorator is the seam.

A war story about images and DI

Back to that Saturday on the federation platform. One pod out of six was running an older image. Five had maxPollIntervalMs: 300_000 in the factory; the sixth had 60_000. The slow downstream call to a federation-rules service occasionally took longer than sixty seconds, so the sixth pod got kicked out of the group, triggering rebalances for everyone. The container itself was fine. The graph it built was different per pod.

First wrong fix was kubectl rollout restart. Consumers re-joined cleanly, then triggered another rebalance forty seconds later. Same dance the group was already doing on its own. Real fix took an hour. Cordon the bad pod, drain the storm in roughly ninety seconds, then pin image SHAs (not :latest) on every Kafka-touching deployment and split the slow downstream call out of the hot consumer loop. About twelve minutes of stale standings during a live broadcast. The federation was understanding. The commentators were not.

Standing rule from that day, now in our CI: any Nest service touching a consumer group fails the deploy if its manifest references :latest. The DI container will happily build whatever graph the image hands it.

Takeaways

  • The container resolves by token, not by class name. Use Symbol tokens for things that aren’t a class.
  • forRootAsync is the dynamic module pattern that survives. Avoid global: true unless you actually want every module to share the dependency.
  • Request scope cascades. Audit the dependency cone before you turn it on. AsyncLocalStorage is almost always the right answer for cross-cutting context.
  • Custom param decorators are the cheapest way to keep controllers testable. Stop passing Request around.
  • The DI graph is only as consistent as the image running the pod. Pin SHAs on anything that touches a consumer group.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.