Two feature modules, OrdersModule and BillingModule, both list CacheService in their providers array. Both inject it in a constructor. Both call cache.increment('hits'). In staging, the counter never goes above the value each module produced on its own — OrdersModule reports 40 hits, BillingModule reports 12, and the total the dashboard shows is wrong by definition, because there's no single counter to be wrong about.
CacheService is decorated with @Injectable(). Nobody set a scope. By every definition you've read, it's a singleton. It is — just not the singleton you assumed.
What you'll learn
By the end of this article you'll be able to:
- Explain what NestJS's dependency injection (DI) container actually does when it sees
constructor(private readonly cache: CacheService) - State precisely what "singleton" means in Nest — and why the same
@Injectable()class can end up as two separate instances - Choose between
useValue,useClass,useFactory, anduseExistingwhen a provider needs more than a bare class - Pick the right provider scope (
DEFAULT,REQUEST,TRANSIENT) and predict the performance and correctness consequences of each - Recognize when a provider's scope "bubbles up" and forces something else in your app to become request-scoped too
Who this is for
You've written at least one NestJS service with @Injectable() and injected it into a controller's constructor. You don't need to have written a custom provider, a factory, or a scoped provider yet — we'll build all three from nothing.
This article is written against NestJS 12.x (verified against the nestjs/nest GitHub release history — v12.0.0 shipped August 27, 2026). Provider registration and scopes are core-container behavior, unchanged in shape across the 10.x → 12.x line; nothing here depends on the v12 ESM migration specifically.
Table of contents
- The problem: a singleton that isn't
- The mental model: registrations, not classes
- Stage 1: the simplest provider
- Stage 2: sharing one instance across modules
- Stage 3: custom providers — when a class isn't enough
- Stage 4: provider scopes — DEFAULT, REQUEST, TRANSIENT
- Stage 5: scope bubbling
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
The problem: a singleton that isn't
Here's the setup, trimmed to the part that matters:
// cache.service.ts
@Injectable()
export class CacheService {
private hits = 0;
increment(key: string) {
this.hits++;
return this.hits;
}
}
// orders.module.ts
@Module({
controllers: [OrdersController],
providers: [OrdersService, CacheService],
})
export class OrdersModule {}
// billing.module.ts
@Module({
controllers: [BillingController],
providers: [BillingService, CacheService],
})
export class BillingModule {}
Both OrdersService and BillingService inject CacheService through their constructors. Both trust that "singleton" means what it usually means in a dependency-injection framework: one instance, shared by whoever asks for it. That trust is reasonable — and wrong here, because of one detail that's easy to skim past: CacheService appears in the providers array of two different modules.
Nest doesn't ask "has this class been instantiated anywhere in the app?" It asks "has this token been registered in this module's injector?" OrdersModule and BillingModule never import each other or a shared module that exports CacheService, so Nest treats the two listings as two independent registrations — and builds two independent instances. Each service gets a real, working, entirely singleton CacheService. They're just not the same one.
The mental model: registrations, not classes
The mental model: NestJS's DI container isn't one global map from class to instance. It's a tree of injectors, one per module, and each injector only knows about the providers that module registered — either directly in its own providers array, or indirectly, imported from another module that exports them.
A provider's real identity is its token (by default, the class itself) plus where it was registered. "Singleton" is a promise about a registration, not about a class name: within one injector's scope, this token resolves to one instance, created once. If a class gets registered twice — once per module, with no import/export connecting the two — you get two injectors, two registrations, two honestly-singleton instances that have never met.
This is why exports matters so much in Nest, and it's the missing piece in the bug above: to actually share one CacheService, exactly one module should own it and export it, and every consumer should import that module instead of re-listing the class.
// cache.module.ts
@Module({
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
// orders.module.ts
@Module({
imports: [CacheModule],
controllers: [OrdersController],
providers: [OrdersService], // CacheService is NOT listed here
})
export class OrdersModule {}
// billing.module.ts
@Module({
imports: [CacheModule],
controllers: [BillingController],
providers: [BillingService], // CacheService is NOT listed here either
})
export class BillingModule {}
Now there's exactly one registration of CacheService, owned by CacheModule. OrdersModule and BillingModule both import it, so Nest resolves the same token to the same instance in both — one counter, correctly shared.
Key concept: if you want one instance across your app, register the provider in exactly one place and import it everywhere else. Never re-list the class in a second module's providers array "to be safe" — that's the line that creates the second instance.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Stage 1: the simplest provider
The smallest possible provider is just a decorated class:
@Injectable()
export class GreetingService {
greet(name: string) {
return `Hello, ${name}`;
}
}
@Injectable() marks the class as something Nest's container is allowed to manage. Listing it in a module's providers array registers it — Nest instantiates it once, resolving its own constructor dependencies first (constructor injection is recursive: if GreetingService needed a LoggerService, Nest would build that first). Anything that injects GreetingService via its constructor type gets the same instance, because the class itself doubles as its injection token.
Stage 2: sharing one instance across modules
Covered above — the fix is exports plus imports, not a second providers listing. It's worth restating as a rule, because it's the single most common DI mistake in a growing Nest app: a provider is shared by being exported and imported, never by being declared twice.
Stage 3: custom providers — when a class isn't enough
Not every dependency is "a class Nest can new up." Configuration objects, third-party SDK clients, and values that depend on other providers all need something more flexible than the shorthand providers: [CacheService]. Nest's provider registration accepts a full object instead, keyed by provide (the token) and one of four resolution strategies:
@Module({
providers: [
// useValue — hand Nest an already-built value
{ provide: 'API_BASE_URL', useValue: 'https://api.example.com' },
// useClass — pick the implementation at registration time
{ provide: PaymentsGateway, useClass: StripeGateway },
// useFactory — build the value at runtime, with its own dependencies
{
provide: 'DB_CONNECTION',
useFactory: (config: ConfigService) => createConnection(config.get('DB_URL')),
inject: [ConfigService],
},
// useExisting — an alias: a second token pointing at the same instance
{ provide: 'LEGACY_CACHE', useExisting: CacheService },
],
})
export class AppModule {}
'API_BASE_URL' and 'DB_CONNECTION' are string tokens — the class-as-token trick only works when the dependency is a class, so a plain value or an interface needs an explicit token instead. Injecting one of these requires @Inject(), since there's no type for Nest to read off the constructor parameter:
@Injectable()
export class PaymentsService {
constructor(@Inject('API_BASE_URL') private readonly baseUrl: string) {}
}
Key concept: useValue, useClass, useFactory, and useExisting are four ways to answer the same question — "what does this token resolve to?" — not four unrelated features. useFactory's inject array is exactly the same resolution the container already does for constructors; it's just spelled out explicitly because a factory function has no constructor for Nest to inspect.
Stage 4: provider scopes — DEFAULT, REQUEST, TRANSIENT
Everything so far assumes the default: one instance, created once at bootstrap, reused for the life of the process. That's Scope.DEFAULT, and you never write it — it's what @Injectable() means with no options. Two other scopes exist, each trading that simplicity for something a shared singleton can't do:
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
// A new instance is created for every incoming request,
// and garbage-collected once that request finishes.
}
@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {
// A new instance is created for every consumer that injects it —
// not shared, not tied to a request.
}
REQUEST scope is for state that's genuinely per-request — the authenticated user, a request ID for tracing, tenant context in a multi-tenant app. TRANSIENT is for the rarer case where you don't want sharing at all, even within one request — a logger that should carry the name of whichever class asked for it is the textbook example.
Both cost something a singleton doesn't. NestJS's own documentation is explicit that request-scoped providers affect performance, because the container can no longer build the dependency graph once at bootstrap — it has to rebuild the request-scoped branch on every request. A properly designed app shouldn't lose more than roughly 5% latency to it, but "properly designed" is doing real work in that sentence: reach for REQUEST scope only for state that actually varies per request, not as a default habit.
For multi-tenant apps where many requests share the same tenant, Nest also supports durable providers — @Injectable({ scope: Scope.REQUEST, durable: true }) — which let the container reuse a request-scoped sub-tree across requests that share a common attribute (like a tenant ID) instead of rebuilding it every single time.
Stage 5: scope bubbling
Scopes aren't isolated to the provider that declares them. If OrdersController injects RequestContextService (request-scoped) directly, OrdersController itself becomes request-scoped — Nest has to create a new controller instance per request too, because it can't build a DEFAULT-scoped controller once and hand it a dependency that only exists per-request. This is called scope bubbling: request scope propagates up the entire chain of things that (directly or transitively) depend on it.
TRANSIENT doesn't bubble the same way. A DEFAULT-scoped service that injects a TRANSIENT logger stays DEFAULT-scoped — it just gets its own private logger instance, created once, same as any other dependency at bootstrap. Transience only means "not shared between different consumers," not "recreated per request."
Key concept: before marking any provider REQUEST-scoped, check what already depends on it. One request-scoped leaf can turn an entire branch of your app — including controllers — into something rebuilt on every single request.
Edge cases and gotchas
-
Two
providerslistings, zero shared state. The bug that opened this article. If a "singleton" seems to be losing state, check whether it's registered in more than one module instead of exported from one and imported everywhere else. -
Circular provider dependencies. If
ServiceAneedsServiceBandServiceBneedsServiceA, Nest can't decide which to build first.forwardRef(() => ServiceB)on both sides breaks the deadlock — but a true circular dependency between services is usually a sign one of them should be split. -
Injecting a request-scoped provider into a
DEFAULT-scoped one you don't control (a library service, for instance) silently makes that dependency chain request-scoped too, even though nothing about its own code changed. The bubbling happens at the injection site, not the declaration site. -
useFactorydependencies must be listed ininject, in the same order as the factory's parameters. Nest resolves them positionally; a factory that takes(config, logger)but declaresinject: [LoggerService, ConfigService]will hand each argument the wrong provider, with no error — just quietly wrong values. -
String tokens collide across modules if you're not careful.
'CACHE'in one module and'CACHE'in another are the same token as far as a shared injector is concerned. Prefer aSymbol()or an app-wide constants file for non-class tokens once you have more than a couple.
Best practices
-
Default to
Scope.DEFAULT. It's the fastest option and correct for the overwhelming majority of providers — anything that doesn't hold per-request state. -
Own shared providers in one module, export them, and import that module everywhere else. Never re-declare the same class in two
providersarrays as a shortcut. -
Reach for
REQUESTscope only for data that's truly per-request (the current user, a correlation ID, tenant context) — and remember it will make everything upstream of it request-scoped too. -
Reach for
TRANSIENTscope only when sharing would actually cause a bug — a logger that should identify its caller is the common case; most services don't need it. - Use string/symbol tokens for anything that isn't a class — config values, third-party clients, interfaces — and keep them in one place so two modules never accidentally collide on the same string.
-
In tests, override providers rather than constructing real ones.
Test.createTestingModule({...}).overrideProvider(CacheService).useValue(fakeCache)swaps a token's resolution for a test double without touching how the rest of the module is wired — it's the exact same token/registration mechanism this article covers, aimed at a test double instead of the real class.
🧠 Test yourself
Think it clicked? Take the 9-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
FAQ
Is a NestJS provider actually a singleton?
Within one registration, yes — DEFAULT scope guarantees one instance for the life of the app for that specific registration. It is not automatically an app-wide singleton if the same class is registered separately in more than one module; that produces multiple, independently "singleton" instances.
How do I get two different instances of the same service on purpose?
Either mark it Scope.TRANSIENT (a fresh instance per consumer), or register it twice under two different tokens using useClass — e.g. { provide: 'PRIMARY_DB', useClass: DbConnection } and { provide: 'REPLICA_DB', useClass: DbConnection }.
Does exporting a provider create a new instance?
No. exports doesn't instantiate anything — it makes an existing registration visible to modules that import the module doing the exporting. The instance is still created once, by whichever module has it in providers.
Why does @Inject() show up on some constructor parameters and not others?
Nest can use a class as its own injection token automatically, because TypeScript's type metadata gives it something to match. A string, symbol, or interface token has no runtime type to read, so @Inject('TOKEN') tells Nest explicitly what to resolve.
Does REQUEST scope work the same way in WebSocket gateways and microservices?
Request-scoped providers are supported outside plain HTTP controllers too, but "request" means whatever triggers a handler in that transport (a socket event, a message) — always check that the perf tradeoff still makes sense for a transport that may see much higher throughput than typical HTTP traffic.
Cheat sheet
| Need | Syntax | Notes |
|---|---|---|
| Basic provider | providers: [MyService] |
Shorthand for { provide: MyService, useClass: MyService }
|
| Share one instance across modules | Export from an owner module, imports it elsewhere |
Never re-list the class in a second providers array |
| Provide a plain value | { provide: 'TOKEN', useValue: x } |
Needs @Inject('TOKEN') at the injection site |
| Swap implementations | { provide: Base, useClass: Impl } |
Consumers still inject Base
|
| Build at runtime with deps | { provide: 'X', useFactory: fn, inject: [...] } |
inject order must match fn's parameter order |
| Alias an existing token | { provide: 'ALIAS', useExisting: Real } |
Same instance, second name |
| One instance for the app |
@Injectable() (default) |
Scope.DEFAULT, built once at bootstrap |
| One instance per request | @Injectable({ scope: Scope.REQUEST }) |
Bubbles up to every consumer; ~5% latency cost when used narrowly |
| One instance per consumer | @Injectable({ scope: Scope.TRANSIENT }) |
Doesn't bubble; each injector gets its own copy |
| Reuse a REQUEST sub-tree by tenant | @Injectable({ scope: Scope.REQUEST, durable: true }) |
Multi-tenant optimization |
| Override in tests | Test.createTestingModule().overrideProvider(X).useValue(fake) |
Same token mechanism, aimed at a test double |
Key takeaways
- A provider's identity in Nest is its token plus its registration — not just its class name. The same class registered in two modules is two instances.
- Share one instance by exporting it from a single owning module and importing that module everywhere it's needed — never by listing the class twice.
-
useValue,useClass,useFactory, anduseExistingare four answers to "what does this token resolve to," and non-class tokens need@Inject()because there's no type for Nest to read. -
REQUESTandTRANSIENTscope solve real problems, butREQUESTscope bubbles up the entire dependency chain and comes with a real, if usually small, performance cost — reach forDEFAULTunless you specifically need per-request state.
The CacheService bug from the top of this article has a one-line fix — move it into an exported, imported CacheModule — but the DI container doesn't tell you that's the problem. It just quietly builds what you asked for: two registrations, two instances, two counters, both correct on their own and wrong together. Once you're reading "singleton" as "one instance per registration" instead of "one instance in the app," that class of bug stops being a mystery and starts being something you check for on sight.
What's the DI bug that cost you the most time to track down — a duplicate registration, a scope that bubbled somewhere you didn't expect, or something else? Drop it in the comments.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___