NestJS – Providers and Dependency Injection

July 22, 20268 min readUpdated 9/5/2026

Dependency injection is the part of Nest that feels like magic until you know what it is doing, and then feels like bookkeeping. It is bookkeeping. Nest keeps a registry of things it knows how to build, reads each class's constructor to find out what it needs, and builds them in order.

A provider is anything Nest can construct

@Injectable()
export class QuotesService {
  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly projectsService: ProjectsService,
  ) {}
  // ...
}

@Injectable() marks the class as something the container manages. QuotesService declares that it needs a DataSource and a ProjectsService, and never says where either comes from.

That indirection is the entire benefit, and it is worth being concrete about why. The class can be constructed with a fake DataSource in a test without changing a line of it. Both dependencies are singletons shared with the rest of the application, so there is one connection pool rather than one per consumer. And the constructor is an honest, complete list of what this class needs — which is a form of documentation that cannot go stale, because it stops compiling when it is wrong.

How Nest knows what to inject

TypeScript types are erased at compile time, so by the time this code runs there is nothing left to say that projectsService is a ProjectsService. Injection works because of one tsconfig flag:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true
  }
}

With it on, the compiler emits an extra piece of metadata beside every decorated class, listing its constructor parameter types as real runtime values. Nest reads that list and looks each entry up in the module's registry.

Two consequences follow. First, the class itself is the lookup key — the token — which is why you can write private readonly projectsService: ProjectsService and nothing else. Second, this only works for types that survive to runtime. An interface does not:

// The wrong way. `Notifier` is an interface, so nothing is emitted and there is no
// token to look up. Nest reports it as an unresolvable dependency at index 0.
constructor(private readonly notifier: Notifier) {}

Injecting an abstraction means using a token that exists at runtime, which is what the @Inject forms below are for.

Registration, and the error when you forget

Declaring @Injectable() is half of it. The class also has to appear in a module:

@Module({
  imports: [TypeOrmModule.forFeature([Quote, ContractorProfile]), ProjectsModule],
  controllers: [QuotesController],
  providers: [QuotesService],
})
export class QuotesModule {}

Miss it and the application will not start:

Nest can't resolve dependencies of the QuotesController (?).
Please make sure that the argument QuotesService at index [0] is
available in the QuotesModule context.

This failing at startup rather than on the first request is a deliberate and useful property. The whole graph is built when the application boots, so a wiring mistake cannot hide behind a rarely used endpoint — it stops the deploy instead.

Providers that are not classes

The shorthand providers: [QuotesService] expands to a longer form, and seeing it written out makes the other kinds obvious:

// These two registrations are identical.
providers: [QuotesService]
providers: [{ provide: QuotesService, useClass: QuotesService }]

provide is the token to look up; the rest is how to build it. Separating them gives you three more shapes.

useClass — supply a different implementation for a token. The same mechanism that lets a test swap in a stub.

useValue — an object you already have. Used constantly in tests, where the whole point is to hand a service a collaborator that does nothing:

{ provide: JwtService, useValue: { signAsync: async () => 'signed.jwt.token' } }

useFactory — build it, possibly asynchronously, possibly using other providers. This is the important one.

Async factories

Some modules cannot be configured with a literal because the values they need come from somewhere that is itself injected. The JWT module is the canonical case: its secret lives in configuration, and configuration is a provider.

JwtModule.registerAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService<AppConfig>) => {
    const jwt = config.getOrThrow<AppConfig['jwt']>('jwt')
    return {
      secret: jwt.secret,
      signOptions: {
        algorithm: 'HS256',
        expiresIn: jwt.expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
      },
      verifyOptions: { algorithms: ['HS256'] },
    }
  },
}),

inject lists the tokens to resolve; they arrive as the factory's arguments in the same order. Nest resolves them first, then calls the factory, then registers the result.

The synchronous register() would have to read process.env directly here, which would put a second, untyped copy of the configuration next to the parsed one — and those two eventually disagree. The suffix is Async by convention across the ecosystem: forRootAsync for the root-level version, registerAsync for the per-module one.

Named tokens

When the thing being injected is not a class, the token is a string or a symbol:

// Providing a value under a name.
{ provide: 'UPLOAD_LIMITS', useValue: { maxBytes: 5 * 1024 * 1024 } }

// Asking for it.
constructor(@Inject('UPLOAD_LIMITS') private readonly limits: UploadLimits) {}

@Inject is required here because there is no class for the metadata to record. Prefer an exported constant over a bare string — a typo in one of two string literals is a runtime failure, while a typo in an imported identifier is a compile error.

This is also what the library helpers are doing underneath. @InjectDataSource() and @InjectRepository(User) are @Inject with a token the library computed. That detail matters the moment you write a test:

{
  provide: getDataSourceToken(),
  useValue: {
    getRepository: () => ({ findOne: async () => storedUser }),
  },
},

Overriding the DataSource class instead would compile, run, and leave the service holding the real connection — the mock would simply never be looked up. Lesson 19 returns to this.

Swapping an implementation

The reason tokens and construction are separate is that it lets one be changed without the other. Nothing that injects a service can tell what it actually got:

// Production
{ provide: PaymentGateway, useClass: StripeGateway }

// A staging build with no real card processing
{ provide: PaymentGateway, useClass: FakeGateway }

Every consumer still declares private readonly payments: PaymentGateway and is untouched by the change. This is what people mean when they call DI "inversion of control" — the decision about which implementation to use moves out of the class that uses it and into the module that assembles the application.

It is worth being honest that this pays off less often than tutorials imply. Most services have exactly one implementation and always will. The version of it that earns its keep every day is the narrower one: a test replacing a collaborator, which is the same mechanism used for five minutes rather than forever.

Injection scopes

Every provider is a singleton by default: constructed once, shared by every request. That is the right default and it is why Nest's per-request overhead is small — the graph is built at startup and never rebuilt.

Two other scopes exist:

@Injectable({ scope: Scope.REQUEST })
export class RequestScopedThing {}

Scope.REQUEST constructs a new instance per request, which lets it inject the request itself. Scope.TRANSIENT gives every consumer its own copy.

Both are worth avoiding unless you have a specific reason. Request scope is contagious: anything that injects a request-scoped provider becomes request-scoped too, and the effect propagates up the graph until you are rebuilding a large part of the application on every request. This codebase uses default scope everywhere, and the per-request state that would otherwise justify request scope — who is signed in — is attached to the request by a guard and read by a parameter decorator instead. That is cheaper and easier to follow.

What this looks like in a service

Nothing about a service's body announces that it was injected. It just uses what it was given:

@Injectable()
export class ProjectsService {
  constructor(@InjectDataSource() private readonly dataSource: DataSource) {}

  async create(user: AuthenticatedUser, dto: CreateProjectDto) {
    if (dto.budgetMax < dto.budgetMin) {
      throw new BadRequestException('The top of the budget cannot be below the bottom of it.')
    }

    const category = await this.dataSource
      .getRepository(ServiceCategory)
      .findOne({ where: { publicId: dto.categoryId } })
    if (!category) throw new BadRequestException('Pick a service category.')
    // ...
  }
}

There is no HTTP in it, no req, no response. It throws exceptions with meaningful names and lets something further out decide what status code those become. That is what makes it callable from a controller, from another service, from a CLI script or from a test — which is the practical payoff of the whole arrangement.

Circularity and lifecycle

Two providers that inject each other cannot be built, because neither can be first. Nest detects it and reports the cycle by name at startup. There is a forwardRef(() => Other) escape hatch, and it is nearly always worth treating a cycle as a design result rather than a problem to suppress: two classes that need each other usually contain a third that belongs on its own.

Providers also have lifecycle hooks, which are methods Nest calls if they exist:

@Injectable()
export class SearchIndex implements OnModuleInit, OnApplicationShutdown {
  async onModuleInit() {
    // runs once, after this module's providers are constructed
  }

  async onApplicationShutdown(signal?: string) {
    // runs on SIGTERM, if shutdown hooks are enabled
  }
}

Use onModuleInit for work that has to happen once and cannot go in a constructor — anything asynchronous, mainly. Constructors cannot be async, and doing I/O in one means an object that exists before it is usable.

onApplicationShutdown only fires if you have called app.enableShutdownHooks(), which is easy to forget and is the usual reason a cleanup handler "does not work". This application does not use either hook: its one connection pool is managed by TypeOrmModule, which registers its own.

One practical note on ModuleRef, which you will meet in older codebases: moduleRef.get(SomeService) resolves a provider at runtime rather than through a constructor. It is occasionally necessary — resolving something whose identity is only known at request time — and is mostly a sign that a dependency should have been declared.

The rules worth remembering

A class is injectable when it has @Injectable() and appears in a module's providers. It is injectable elsewhere only when that module also exports it and the other module imports it. The token is normally the class, and when it cannot be — an interface, a plain value, a library's connection — you name it explicitly and ask for it with @Inject. Everything is a singleton unless you deliberately say otherwise, and you usually should not.

Next: DTOs and validation — the classes that describe what a request body is allowed to contain, and the one line in main.ts without which none of them do anything at all.