NestJS Dependency Injection: tokens, providers, useClass vs useExisting Intermediate
Estimated reading time: 7 min read
The problem
Section titled “The problem”CheckoutService needs to call a payment gateway. If it does new PaymentGateway() itself, every test has to build a real gateway, and switching gateways means editing the whole checkout flow. DI inverts this: a module registers a provider under a token, and the service only asks for the token.
DI does not rescue a design that lacks boundaries. It only makes clear who supplies which dependency and how long an instance lives. This article covers the container’s mechanics. How to design the port and adapter for a payment gateway is in Adapter pattern.
Four things to tell apart
Section titled “Four things to tell apart”| Concept | What it is | Example |
|---|---|---|
| Token | The runtime key used to look up a provider: a class, string or symbol | PAYMENT_PORT = Symbol('PAYMENT_PORT') |
| Provider | How the value for a token is created: useClass, useValue, useFactory, useExisting |
{ provide: PAYMENT_PORT, useClass: DemoGateway } |
| Module | Visibility scope: a provider is visible only in the module that declares it, unless exported | exports: [PAYMENT_PORT] |
| Scope | Instance lifetime: DEFAULT, REQUEST, TRANSIENT |
See provider scopes |
TypeScript interface and type disappear after compilation, so they cannot be tokens. Use a class, symbol or string with @Inject(token); a symbol is safer than a string because names cannot collide across modules.
Registering and resolving
Section titled “Registering and resolving”The real code from di-demo.ts:
const PAYMENT_PORT = Symbol('PAYMENT_PORT');type PaymentPort = { charge(orderId: number): Promise<string> };
@Injectable()class CheckoutService { constructor(@Inject(PAYMENT_PORT) private readonly payments: PaymentPort) {} checkout(orderId: number) { return this.payments.charge(orderId); }}
@Module({ providers: [CheckoutService, { provide: PAYMENT_PORT, useClass: DemoPaymentGateway }],})class CheckoutModule {}Loading diagram…
At bootstrap, Nest scans the module graph, registers providers, then resolves constructors by token. Running the demo prints payment-for-42 and same default-scoped instance: true: two app.get calls return the same instance because of the default scope.
The instance is shared only within one application context; 10 replicas means 10 instances, so a Map inside a provider is not shared state across replicas.
useClass and useExisting
Section titled “useClass and useExisting”These two are often confused. Register AuditLog as a provider, then create a second token pointing at it:
| Configuration | Instances | Measured result (Nest 11) | |
|---|---|---|---|
| Before | { provide: 'AUDIT_CLASS', useClass: AuditLog } |
2 (each binding creates its own) | app.get(AuditLog) === app.get('AUDIT_CLASS') is false |
| After | { provide: 'AUDIT_ALIAS', useExisting: AuditLog } |
1 (alias) | true |
// Want two tokens on one instance (same connection, same state): use useExisting.providers: [AuditLog, { provide: 'AUDIT_ALIAS', useExisting: AuditLog }]Use useClass to choose a class by environment (for example a fake for tests, the real adapter for production). Use useExisting when you want an alias. If a singleton holds a connection or important state, check identity instead of inferring it from the class name.
Advanced providers
Section titled “Advanced providers”All of these run in providers-demo.ts; the real output is below.
| Need | How | Note |
|---|---|---|
| Static value, test fake | useValue |
Don’t hard-code secrets in a module |
| Initialization needs config or is asynchronous | useFactory (can be async) with inject: [...] |
Nest waits for the factory before creating dependent classes |
| A dependency may be absent | @Optional() or { token, optional: true } in inject |
A missing provider yields undefined, no startup error |
| A module needs configuration at import time | Dynamic module: static forRoot(options) returns a DynamicModule |
Nest convention: forRoot configures once, register is per calling module, forFeature uses the forRoot configuration and tweaks part of it for the calling module |
| Something used everywhere (config, logger) | @Global() |
Only for truly global things; overuse erases boundaries |
@Optional missing provider -> options = undefinedAsync provider: DB starts connecting -> DB connected -> Repo createdDynamic module -> config = { region: 'ap-southeast-1' }// async provider{ provide: 'DB', useFactory: async () => await connect(),}
// dynamic module@Module({})class ConfigModule { static forRoot(options: { region: string }): DynamicModule { return { module: ConfigModule, providers: [{ provide: 'CONFIG', useValue: options }], exports: ['CONFIG'], }; }}Custom providers are also confined to the declaring module. For another module to use one, exports it by token (exports: ['CONFIG']) or by the provider object itself. A factory should only initialize; connections that need closing belong in a lifecycle hook at shutdown.
Real-world example
Section titled “Real-world example”Two experiments that surface concrete errors: delete the { provide: PAYMENT_PORT, ... } binding and the container cannot resolve CheckoutService; change @Inject to a new Symbol('PAYMENT_PORT') and it also fails, because the two symbols differ despite the same label.
For tests, Test.createTestingModule() with overrideProvider() swaps a provider without editing the source.
Production checklist
Section titled “Production checklist”- Use symbols or classes as tokens; avoid free-form strings that can collide across modules.
- Each module exports the minimum necessary;
@Global()only for config and logger. - Use
useExistingfor aliases, and check identity when a singleton holds state or a connection. - Async/factory providers only initialize; close connections in a lifecycle hook at shutdown.
- If you need
forwardRef()in many places, revisit module boundaries instead of papering over them withforwardRef. - A constructor with too many dependencies is a signal to split the use case, not a reason to build a service locator.
- Don’t keep business state in fields of default-scoped providers when running multiple replicas.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why isn’t type PaymentPort enough for Nest to resolve? (2) Two tokens point to the same class: when are there two instances? (3) PaymentsModule has the provider but forgot to export it: what do other modules see?
Run the demos:
npx --prefix examples ts-node examples/src/nestjs-di/di-demo.tsnpx --prefix examples ts-node examples/src/nestjs-di/providers-demo.ts