Modules, providers & the injector
- Fix a 'Nest can't resolve dependencies' error with exports and imports, and say what @Global() costs
- Predict instance sharing: one singleton for all consumers, two from duplicate providers, and how far Scope.REQUEST spreads
- Inject an interface-typed dependency with a Symbol token and @Inject, and swap in a fake for tests
- Choose useFactory with inject for async setup, and keep connection work out of constructors
Read
A module is a registry; the injector is a lookup
FlagThis unit assumes the vocabulary from the previous one — decorator, controller, service,
constructor(private readonly x: T). Nest looks magical until you name the two moving parts:
- A provider is anything the injector can hand out — usually a class marked
@Injectable(). - A module is the registry that says which providers exist, which of them leave the module, and which other modules it can borrow from.
At boot Nest walks the module graph once, reads each class's constructor parameter types,
and builds one instance of each provider (that's the default scope — the Complex tier covers
per-request and per-consumer providers). That's it — constructor(private users: UsersService)
means "look up the provider registered under the token UsersService and pass it in". A
token is just the key a provider is filed under; for a class provider, the class itself is the
key.
@Injectable()
export class UsersService {
findAll() { return [{ id: "1" }]; }
}
@Controller("users")
export class UsersController {
// Not `new UsersService()` — the injector supplies the instance it already built.
constructor(private readonly users: UsersService) {}
@Get()
findAll() { return this.users.findAll(); }
}
@Module({
controllers: [UsersController],
providers: [UsersService], // registered here -> resolvable inside this module
})
export class UsersModule {}
Two consequences worth internalizing on day one:
- You never call
newon a service. The class's dependencies are declared, not constructed, so a test can pass a fake in the same position. - Providers are singletons by default — one instance per application, shared by every consumer (anything that injects it — a controller, another service, a guard). A field you set on a service is shared state for the whole process, not per request.
Reading "Nest can't resolve dependencies of the X"
FlagThis is the error you will meet most often, and it tells you exactly where to look. Nest 12 prints it like this (one long first line, wrapped here), followed by a list of potential solutions:
Nest can't resolve dependencies of the OrdersService (?, ConfigService). Please make sure
that the argument PricingService at index [0] is available in the OrdersModule module.
Read it as three facts:
- Who needs something —
OrdersService. - Which argument — the
?marks the unresolved constructor parameter; index[0]. - Which registry was searched —
OrdersModule. Not "the app": Nest modules are encapsulated, so only this module's ownprovidersplus theexportsof itsimportsare in scope.
So there are exactly four causes:
- The provider isn't in any module's
providers. - Its owning module doesn't
exportsit. OrdersModuledoesn'timportsthe owning module.- The type is erased at runtime — an
interface, animport type, or a circular import — so there is no token to look up (see the Complex tier). You can tell this one from the error itself: it names no token and says "is available in the current module".
Encapsulation, in one picture
@Module({ providers: [PricingService] }) // not exported
export class PricingModule {}
@Module({ imports: [PricingModule], providers: [OrdersService] })
export class OrdersModule {} // 💥 PricingService is private
Importing a module gets you its exports, nothing more. exports: [PricingService] is
the one-line fix, and refusing to add it is a legitimate design decision: an unexported
provider is an implementation detail of that module.
Global modules, and why to be sparing
@Global() on a module makes its exports injectable everywhere without an imports entry.
It's right for exactly one class of thing — cross-cutting infrastructure registered once at
the root (config, logger, database connection). Reach for it more often and the module graph
stops documenting anything: every service looks like it depends on nothing, and boot-order
bugs get much harder to see.
Why this shape is worth the ceremony
Because the same seam that Nest uses to inject the real provider is the one your test uses to inject a fake:
const moduleRef = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: PricingService, useValue: { quote: () => 100 } },
],
}).compile();
const orders = moduleRef.get(OrdersService);
No module mocking, no import interception — you overrode a token. That is the entire payoff of DI, and it's why "just import the singleton directly" costs you later.
Tokens, factories and scopes
Flagproviders: [UsersService] is shorthand for the full form:
{ provide: UsersService, useClass: UsersService }
Once you see the long form, the rest of the provider system is just choosing a token and a strategy.
Four strategies
@Module({
providers: [
UsersService, // useClass shorthand
{ provide: "CLOCK", useValue: { now: () => new Date() } }, // a value/fake
{
provide: PaymentGateway, // swap the implementation
useClass: process.env.NODE_ENV === "test" ? FakeGateway : StripeGateway,
},
{
provide: "REDIS", // needs async setup / other providers
useFactory: async (config: ConfigService) => {
const client = createClient({ url: config.get("REDIS_URL") });
await client.connect(); // node-redis (v4 onward) doesn't connect on create — await it here
return client; // consumers get a client that's ready to use
},
inject: [ConfigService],
},
],
})
export class InfraModule {}
useFactory is the escape hatch for anything you can't express as "call the constructor":
async connections, values that depend on config, one provider assembled from several others.
inject is positional — it feeds the factory's parameters in order.
Interfaces can't be tokens
The injector reads runtime metadata, and TypeScript interfaces don't exist at runtime.
constructor(private mailer: Mailer) where Mailer is an interface gives you an
unresolvable dependency. Use a string/symbol token — declared once, next to the interface —
plus @Inject:
export const MAILER = Symbol("MAILER");
export interface Mailer { send(to: string, body: string): Promise<void> }
constructor(@Inject(MAILER) private readonly mailer: Mailer) {}
An abstract class works too, and keeps the type and the token as one symbol.
Scopes: singleton is a decision, not a default you can ignore
| Scope | Instances | Use when |
|---|---|---|
DEFAULT | One per app | Almost always — services must be stateless |
REQUEST | One per request | You need the request (tenant, user) inside a deep provider |
TRANSIENT | One per consumer | Each injector wants its own copy (e.g. a scoped logger) |
Request scope is contagious — it bubbles up the injection chain:
(-> means "injects")
OrdersController -> OrdersService -> TenantContext @Injectable({ scope: Scope.REQUEST })
per request now per request now per request
OrdersController -> PricingService still one instance: it never reaches TenantContext
Everything that depends on a request-scoped provider, directly or through others, is rebuilt
for every request, controller included; providers off that path stay singletons. That's a real
throughput cost, and it's why AsyncLocalStorage (or passing the value as an argument) often
beats Scope.REQUEST for "the current user/tenant". The opposite mistake — keeping the current
user in a field on a singleton — is the shared-state bug from the previous unit.
Quiz
OrdersService injects PricingService, which is declared in PricingModule's providers. Boot fails with "Nest can't resolve dependencies of the OrdersService (?)". What are the two things to check?
What is the actual consequence of listing the same service class in two modules' providers arrays?
constructor(private mailer: Mailer) where Mailer is a TypeScript interface fails to resolve. Why?
You need a Redis client whose URL comes from ConfigService and whose connection is established asynchronously. Which provider form?
TenantContext is @Injectable({ scope: Scope.REQUEST }). OrdersService injects it; OrdersController injects OrdersService and PricingService (which doesn't touch TenantContext). Which of these are created per request?
What do you give up by marking many modules @Global()?
Fill in the blank
Let another module inject OrdersService while keeping OrdersRepository private to this module.
@Module({ imports: [DatabaseModule], controllers: [OrdersController], providers: [OrdersService, OrdersRepository], : [OrdersService], }) export class OrdersModule {}
Inject an implementation behind an interface, using an explicit symbol token.
Flagexport const MAILER = Symbol("MAILER"); @Injectable() export class SignupService { constructor(@(MAILER) private readonly mailer: Mailer) {} }
Build a provider asynchronously from another provider.
Flag{ provide: "REDIS", : async (config: ConfigService) => { const client = createClient({ url: config.get("REDIS_URL") }); await client.connect(); return client; }, : [ConfigService], }
Replace a real dependency with a fake in a unit test, using the same token the injector uses.
Flagconst moduleRef = await Test.createTestingModule({ providers: [ OrdersService, { : PricingService, : { quote: () => 100 } }, ], }).compile();
Predict the output
@Injectable()
class CounterService {
private hits = 0;
bump() { return ++this.hits; }
}
// CounterService is in ONE module's providers; two controllers inject it.
// Request 1 hits ControllerA -> counter.bump()
// Request 2 hits ControllerB -> counter.bump()
// Request 3 hits ControllerA -> counter.bump()@Injectable()
class CacheService {
store = new Map<string, string>();
}
@Module({ providers: [CacheService, AService], exports: [AService] })
class AModule {}
@Module({ providers: [CacheService, BService], exports: [BService] })
class BModule {}
// AService: this.cache.store.set("k", "v")
// then BService: this.cache.store.get("k")@Injectable()
class DbService implements OnModuleInit {
ready = false;
constructor() { this.connect(); } // async, not awaited
async connect() { await sleep(50); this.ready = true; }
async onModuleInit() { console.log("init:", this.ready); }
}
// A controller handles a request immediately after boot and logs `db.ready`.