ModuleRef, Circular Dependencies & Lazy Loading
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. The examples use a project generated by the Nest 12 CLI (ESM layout, Vitest, supertest), and the output shown is what they produce. The Madge output comes from Madge 8.0.0, run with npx against the same sources.
The container normally builds the provider graph for you: you declare constructor dependencies and Nest injects them (see Providers & Dependency Injection and Modules). This page covers the three situations in which that static wiring is not enough or gets in the way: two classes or modules that need each other (circular dependencies), code that must look a provider up by token when it runs (ModuleRef), and modules that should only be loaded when they are needed (LazyModuleLoader). The running scenario is Bookshelf: a BooksService that wants review summaries and a ReviewsService that wants book titles.
Circular dependencies
A circular dependency exists when A needs B and B needs A, directly or through a longer chain. It can occur between providers and between modules, and it can also exist at the file level: the two TypeScript files import each other, even when Nest could build the instances. Nest cannot instantiate a class before its dependencies, so with a cycle it does not know which side to build first.
Why a cycle usually signals a design problem
The official documentation says to avoid circular dependencies where possible, and the reason is architectural. If BooksService and ReviewsService must know each other, they are probably two halves of one concept, or both depend on something that has no name yet. The cycle also makes behaviour fragile: the docs warn that the order of instantiation is indeterminate, so code must not depend on which constructor runs first, and that cycles involving request-scoped providers can leave dependencies undefined. Typical causes are a shared helper that lives in one of the two classes, and barrel files (index.ts): the docs advise against using barrel files for module and provider classes, because a file that imports its own directory’s barrel creates a cycle of its own.
Treat forwardRef() as a repair for a cycle you cannot remove right now, not as a design tool. The usual ways out are:
-
extract the logic both sides need into a third provider that depends on neither (shown below);
-
invert one direction with events (an event emitter, or a message) so that one side no longer calls the other;
-
look the provider up lazily with
ModuleRefon one side (see ModuleRef: looking providers up at run time).
Source: Circular dependency.
forwardRef() between providers
forwardRef() (from @nestjs/common) wraps a function that returns the class, so Nest reads the class later, once it is defined. Both sides of the relationship must use it, together with @Inject():
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import type { Relation } from '../relation.js';
import { ReviewsService } from './reviews.service.js';
@Injectable()
export class BooksService {
constructor(
@Inject(forwardRef(() => ReviewsService))
private readonly reviews: Relation<ReviewsService>,
) {}
titleOf(bookId: number): string {
return bookId === 1 ? 'Dune' : 'Unknown';
}
withSummary(bookId: number): string {
return this.reviews.summary(bookId);
}
}
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import type { Relation } from '../relation.js';
import { BooksService } from './books.service.js';
@Injectable()
export class ReviewsService {
constructor(
@Inject(forwardRef(() => BooksService))
private readonly books: Relation<BooksService>,
) {}
titleOf(bookId: number): string {
return this.books.titleOf(bookId);
}
summary(bookId: number): string {
return `${this.titleOf(bookId)}: 4.5 stars`;
}
}
// Wrapping the type makes TypeScript emit `Object` for design:paramtypes.
export type Relation<T> = T;
The Relation<T> alias is not part of the NestJS docs. It is needed in an ESM project (the current template), because the files import each other and TypeScript’s decorator metadata would otherwise reference a class that is still uninitialized when the module is evaluated; see TypeScript for NestJS for the details. The official example, which uses @Inject(forwardRef) ⇒ CommonService with a plain type, is written for CommonJS and for JavaScript (@Dependencies(forwardRef(…))). Resolving both providers and calling across the cycle gives:
Dune: 4.5 stars
forwardRef() between modules
A cycle between two modules (each lists the other in imports) is resolved the same way, on both sides, in the imports array:
import { forwardRef, Module } from '@nestjs/common';
import { BooksService } from './books.service.js';
import { ReviewsModule } from './reviews.module.js';
@Module({
imports: [forwardRef(() => ReviewsModule)],
providers: [BooksService],
exports: [BooksService],
})
export class BooksModule {}
import { forwardRef, Module } from '@nestjs/common';
import { BooksModule } from './books.module.js';
import { ReviewsService } from './reviews.service.js';
@Module({
imports: [forwardRef(() => BooksModule)],
providers: [ReviewsService],
exports: [ReviewsService],
})
export class ReviewsModule {}
Importing both modules into a testing module compiled and started, and BooksService and ReviewsService could be retrieved and called. A module cycle is separate from a provider cycle: when the providers inside the two modules also depend on each other, you need both the module-level and the provider-level forwardRef(), as in this example. When only the modules import each other but no provider needs the other module’s providers, the module-level forwardRef() is enough, and then a restructure is usually a better answer.
Removing the cycle: a third provider
The cleaner fix is to move what both sides use into a provider of its own. Here the book titles live in CatalogService, and nothing depends back on SlimBooksService:
import { Injectable, Module } from '@nestjs/common';
@Injectable()
export class CatalogService {
titleOf(bookId: number): string {
return bookId === 1 ? 'Dune' : 'Unknown';
}
}
@Injectable()
export class SlimReviewsService {
constructor(private readonly catalog: CatalogService) {}
summary(bookId: number): string {
return `${this.catalog.titleOf(bookId)}: 4.5 stars`;
}
}
@Injectable()
export class SlimBooksService {
constructor(
private readonly catalog: CatalogService,
private readonly reviews: SlimReviewsService,
) {}
withSummary(bookId: number): string {
return `${this.catalog.titleOf(bookId)} / ${this.reviews.summary(bookId)}`;
}
}
@Module({
providers: [CatalogService, SlimReviewsService, SlimBooksService],
exports: [SlimBooksService],
})
export class SlimCatalogModule {}
Dune / Dune: 4.5 stars
There is no forwardRef(), no @Inject() and no Relation<T>; the graph is a plain tree and the ordinary constructor injection works. This is the shape to aim for.
Source: Circular dependency (the docs recommend refactoring or ModuleRef as alternatives to forwardRef()).
Detecting cycles with Madge
Nest reports a cycle only when it hits one at start-up, and a file-level import cycle may not fail at all. Madge is a third-party tool (not part of NestJS) that builds the import graph of a project and lists circular imports, so you can find cycles before they bite and keep them out in CI. Run it with npx, pointing it at TypeScript sources and the project’s tsconfig.json so it resolves TypeScript imports and path aliases:
npx madge --circular --extensions ts --ts-config tsconfig.json src
On the two-service example above, Madge 8.0.0 reports both the provider and the module cycle:
✖ Found 2 circular dependencies!
1) books.service.ts > reviews.service.ts
2) books.module.ts > reviews.module.ts
and on the refactored catalog.ts it reported ✔ No circular dependency found!. madge --circular exited with status 1 when it found a cycle, which makes it usable as a CI step (for example as an npm script). Two practical notes. forwardRef() does not remove the import cycle between the files, so Madge keeps listing a cycle that you chose to keep; decide per cycle, or exclude it explicitly with --exclude. And --image <file> writes the graph to an image if you want to see it, which needs Graphviz installed. Nest Devtools can also visualise the module graph (see Modules).
Source: Madge, "Usage".
ModuleRef: looking providers up at run time
ModuleRef (from @nestjs/core) is a built-in provider that gives access to the container: you can fetch an instance by its injection token, resolve scoped providers, and instantiate classes that were never registered. Inject it like any other dependency. It is the tool behind patterns where the class to use is only known at run time (a strategy picked by name, a handler chosen by message type) and one way out of a cycle.
get(): static providers
get() returns an instance of a provider, controller or injectable (guard, interceptor and so on) registered in the current module, by class or token. It throws when nothing is found. Because it needs the container to be built, call it from onModuleInit() or later, not from a constructor:
@Injectable()
export class DynamicLookupService implements OnModuleInit {
clock!: ClockService;
constructor(private readonly moduleRef: ModuleRef) {}
async onModuleInit(): Promise<void> {
// get(): the singleton of the current module
this.clock = this.moduleRef.get(ClockService);
}
}
By default the lookup is strict: the provider must be declared in the module that owns the ModuleRef. Asking a module that does not declare ClockService fails with
Nest could not find ClockService element (this provider does not exist in the current context)
while moduleRef.get(ClockService, { strict: false }) found it in the global context of the application. get() cannot return transient or request-scoped providers (including providers that are request-scoped only because of a dependency). Asking for one failed with:
SlugService is marked as a scoped provider. Request and transient-scoped providers can't be used in combination with "get()" method. Please, use "resolve()" instead.
The same call replaces forwardRef() on one side of a cycle. NotifierService below does not inject AuditService; it fetches it once the container is built, while AuditService keeps its ordinary constructor dependency, so no constructor cycle remains:
@Injectable()
export class NotifierService implements OnModuleInit {
private audit!: AuditService;
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit(): void {
// Looked up once the container is built, so there is no constructor cycle.
this.audit = this.moduleRef.get(AuditService);
}
send(message: string): string {
return `sent ${message}`;
}
auditAndSend(message: string): string {
return this.audit.record(message);
}
}
@Injectable()
export class AuditService {
constructor(private readonly notifier: NotifierService) {}
record(message: string): string {
return this.notifier.send(`audit:${message}`);
}
}
sent audit:hello
NotifierService is declared first because both classes share one file: the metadata of the AuditService constructor refers to NotifierService, and a class used before its declaration is still uninitialized (the opposite order fails with Cannot access 'NotifierService' before initialization).
resolve(): transient and request-scoped providers
resolve() is asynchronous and returns a unique instance of a scoped provider from its own DI sub-tree. Two calls without a context identifier give two different instances:
@Injectable({ scope: Scope.TRANSIENT })
export class SlugService {
private static created = 0;
readonly id = ++SlugService.created;
}
// inside onModuleInit():
this.slugs = await Promise.all([
this.moduleRef.resolve(SlugService),
this.moduleRef.resolve(SlugService),
]);
// this.slugs[0] === this.slugs[1] -> false (ids 1 and 2)
ContextIdFactory: sharing one sub-tree
To make several resolve() calls share the same instances, pass the same context identifier. ContextIdFactory.create() (from @nestjs/core) returns a new unique identifier; each identifier stands for one DI sub-tree:
const shared = ContextIdFactory.create();
this.sameContext = [
await this.moduleRef.resolve(SlugService, shared),
await this.moduleRef.resolve(SlugService, shared),
];
// this.sameContext[0] === this.sameContext[1] -> true
registerRequestByContextId(): giving a manual sub-tree a request
A sub-tree created with ContextIdFactory.create() is not created by Nest’s request handling, so the REQUEST provider is undefined inside it. When a request-scoped provider injects REQUEST (for example from a queue worker or a scheduled job that has no HTTP request), register the object that should stand in for it with registerRequestByContextId():
@Injectable({ scope: Scope.REQUEST })
export class TenantService {
constructor(@Inject(REQUEST) readonly request: { tenant?: string }) {}
tenant(): string | undefined {
return this.request?.tenant;
}
}
// inside onModuleInit():
const ctxA = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId({ tenant: 'acme' }, ctxA);
this.tenantA = (await this.moduleRef.resolve(TenantService, ctxA)).tenant();
const ctxB = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId({ tenant: 'globex' }, ctxB);
this.tenantB = (await this.moduleRef.resolve(TenantService, ctxB)).tenant();
// no registration: REQUEST is undefined in a manual sub-tree
const ctxC = ContextIdFactory.create();
this.withoutRequest = (await this.moduleRef.resolve(TenantService, ctxC)).tenant();
The output is acme, globex and undefined: each sub-tree gets its own TenantService and its own request object, and the third one has none.
ContextIdFactory.getByRequest(): the current request’s sub-tree
Inside a real request, do not create a new identifier: reuse the one of the current request so that your lookups share the request’s instances. Inject REQUEST, then ask ContextIdFactory.getByRequest():
@Injectable({ scope: Scope.REQUEST })
export class CurrentUserService {
constructor(
@Inject(REQUEST) private readonly request: { headers: Record<string, string> },
private readonly moduleRef: ModuleRef,
) {}
async describe(): Promise<{ user: string; auditSame: boolean }> {
// Reuse the sub-tree of the current request instead of a new one
const contextId = ContextIdFactory.getByRequest(this.request);
const a = await this.moduleRef.resolve(RequestAudit, contextId);
const b = await this.moduleRef.resolve(RequestAudit, contextId);
return { user: this.request.headers['x-user'] ?? 'anonymous', auditSame: a === b };
}
}
curl -H 'x-user: alice' http://localhost:3000/me
{ "user": "alice", "auditSame": true }
Request scope has a cost (a new sub-tree per request, and it bubbles up to every consumer), which is covered in Providers & Dependency Injection and the injection scopes page of this section.
create(): instantiating unregistered classes
create() instantiates a class that is not registered as a provider anywhere, injecting its constructor dependencies from the container. This lets you decide at run time which class to build:
@Injectable()
export class ReportFactory {
constructor(private readonly clock: ClockService) {}
build(): string {
return `report at ${this.clock.now()}`;
}
}
// inside onModuleInit():
this.report = await this.moduleRef.create(ReportFactory);
// this.report.build() -> 'report at 2026-10-02T00:00:00Z'
The class needs a decorator even though it is not registered: without @Injectable() TypeScript emits no constructor metadata, ClockService is not injected and build() fails with Cannot read properties of undefined (reading 'now'). Decorate such classes so the metadata exists; the decorator does not register the class as a provider. The instance is not tracked by the container afterwards.
Lazy-loading modules
By default every module is loaded when the application starts, which is the right behaviour for most servers. In a serverless function or a worker, start-up time is paid on every cold start, so loading only what a given invocation needs can matter. LazyModuleLoader (from @nestjs/core) loads a module on demand and returns its ModuleRef. The official docs stress that this is not Angular’s lazy loading: the feature is only about deferring the loading of providers.
Using LazyModuleLoader
Inject the loader into a class (or get it from the application with app.get(LazyModuleLoader)), import the module file dynamically, and call load() with a function returning the module class. Then retrieve providers from the returned ModuleRef:
@Module({
controllers: [LazyController],
providers: [LazyService],
exports: [LazyService],
})
export class LazyModule {}
import { Injectable } from '@nestjs/common';
import { LazyModuleLoader } from '@nestjs/core';
@Injectable()
export class BillingService {
constructor(private readonly lazyModuleLoader: LazyModuleLoader) {}
async invoice(orderId: number): Promise<string> {
// The module file is only loaded (and compiled into the graph) on first use.
const { LazyModule } = await import('./lazy.module.js');
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule);
const { LazyService } = await import('./lazy.service.js');
return moduleRef.get(LazyService).invoice(orderId);
}
}
Calling billing.invoice(7) returned invoice-7. Details from the docs that matter in practice:
-
The lazy module is an ordinary Nest module, with no special changes.
-
load()caches: the second call for the same module is much faster and returns the cached instance. Twoload(() ⇒ LazyModule)calls return the same module reference. -
The lazy module joins the same module graph as the eagerly loaded modules, and any other lazy modules loaded later.
-
load()accepts a second options argument;{ logger: false }suppresses the log lines Nest writes while loading the module. -
With webpack, set
compilerOptions.moduleto"esnext"andcompilerOptions.moduleResolutionto"node"intsconfig.jsonso webpack’s code splitting works; that is a bundler concern and does not apply to the default ESMtscbuild.
The import() calls use the .js extension, like every relative import in the ESM template.
Limits
Lazy loading comes with firm restrictions:
-
Lifecycle hooks are not called in lazy-loaded modules and services.
LazyService.onModuleInit()is never invoked (a counter it increments stays at0), so do not put initialisation, connections oronModuleDestroy()clean-up in a lazy module. -
Controllers, GraphQL resolvers and WebSocket gateways do not work in lazy modules, and neither does middleware registered through
NestModule.configure(). They describe routes, topics, queries or channels that must exist before the server listens. The docs give reasons: Fastify cannot add routes once the application is listening; some transports (Kafka, gRPC, RabbitMQ) must subscribe to topics or channels before connecting; code-first GraphQL builds its schema from all classes up front. For the HTTP case:GET /lazyon a controller declared inside the lazy module returns404. -
Global modules and global enhancers do not work: a lazy module cannot be global, because it is registered after the static modules are instantiated, and global guards, interceptors and the like registered from it do not take effect.
Lazy modules therefore suit services (providers), such as a PDF generator, a payment adapter or a report builder, and not anything that exposes an entry point.
Serverless and worker use
The docs name the common use case: a worker, cron job, Lambda or other serverless function, or webhook that must run different logic depending on its input (route path, date, query parameters), where lazy loading cuts the cold start. It makes little sense in a long-running monolith, where start-up time is largely irrelevant. A function handler that keeps a small root module and a cached application context between warm invocations looks like this:
import { NestFactory } from '@nestjs/core';
import { BillingModule } from './billing.module.js';
import { BillingService } from './billing.service.js';
// Serverless-style entry point: a tiny root module, one cached context,
// heavy modules loaded per event.
let billing: Promise<BillingService> | undefined;
function bootstrap(): Promise<BillingService> {
billing ??= NestFactory.createApplicationContext(BillingModule, {
logger: false,
}).then((ctx) => ctx.get(BillingService));
return billing;
}
export async function handler(event: { orderId: number }): Promise<string> {
const service = await bootstrap();
return service.invoice(event.orderId);
}
Two events in a row gave invoice-42 and invoice-43; the second reuses the cached promise instead of bootstrapping again. Standalone applications (createApplicationContext) skip the HTTP server altogether, which is what a function usually wants. The serverless chapter of the docs also shows lazy-loading a caching module only for the invocations that need it, so the others start faster.
Version notes for functions: the Nest 12 packages are ESM-only, and a CommonJS application consumes them through require(esm). AWS Lambda disables require(esm) by default on its Node.js 20, 22 and 24 runtimes, so a CommonJS Nest 12 function there needs NODE_OPTIONS=--experimental-require-module in the function’s environment (append it to any existing value).
Where the book is outdated and what changed recently
The book covers forwardRef() and Madge in chapter 4, with CommonJS-era code in mind, where a plain class type next to @Inject(forwardRef(…)) is enough. In the current ESM template that same pair fails at import time with a ReferenceError, so a wrapped type such as Relation<T> is needed (see above and TypeScript for NestJS). The book’s advice to avoid cycles stands, and so do the other tools on this page; for request-scoped providers outside a request, use resolve() with ContextIdFactory and registerRequestByContextId(), because get() refuses scoped providers.
Recent changes, checked on 2026-10-02: the v12 migration guide lists no change to the forwardRef(), ModuleRef or LazyModuleLoader APIs. Release v12.1.1 (2026-09-28) includes the core fix "detect circular dependencies across more than two providers" (nestjs/nest#17871), so cycles that span three or more providers are now detected and reported at start-up. The code on this page targets 12.1; the one version-specific point is the ESM-only packages (above).
Source: NestJS v12.1.1 release notes.