← blog
NestjsSeptember 11, 2026 · 14 min read

NestJS Module Encapsulation Explained (with Cheat Sheet)

How NestJS module boundaries actually work: what exports really cross, why @Global() isn't a shortcut, and how forwardRef breaks circular module dependencies.

Parsa Jiravand · Frontend engineer · building bestpractic
NestJS Module Encapsulation Explained (with Cheat Sheet)
  1. 1NestJS Request Lifecycle Explained (with Cheat Sheet)13 min
  2. 2NestJS Module Encapsulation Explained (with Cheat Sheet)you are here

A NestJS app boots cleanly for weeks. Then a teammate adds NotificationsModule, wires NotificationsService into OrdersService's constructor, runs the app — and Nest refuses to start:

Text
1
2
3
4
5
Nest can't resolve dependencies of the OrdersService (?). Please make sure that the argument NotificationsService at index [1] is available in the OrdersModule context. Potential solutions: - Is OrdersModule a valid NestJS module? - If NotificationsService is exported from a separate @Module, is that module imported within OrdersModule?

Nothing about NotificationsService itself is wrong — it's decorated with @Injectable(), it compiles, its own tests pass in isolation. The teammate even double-checked it's listed in NotificationsModule's providers array. It is. That's not the part that's missing.

By the end of this article you'll be able to:

  • Explain what a NestJS module boundary actually is, and why "it's in providers" isn't the same question as "can this other module see it"
  • Fix the exact error above by understanding what exports really crosses
  • Decide when @Global() is the right tool, and why it's the exception, not the default
  • Recognize a module-level circular dependency and choose between forwardRef() and the better fix: restructuring
  • Read a dynamic module (forRoot/forFeature) as the same encapsulation rules, just built at runtime

You've built at least one multi-module NestJS app — a couple of feature modules, each with its own controller and service. You don't need to have hit the error above yet; we'll build it from nothing and then fix it properly.

This article is written against NestJS 12.x (verified against the nestjs/nest GitHub release history — v12.0.0 shipped August 27, 2026). Module registration, exports, @Global(), and forwardRef() are core-container behavior, unchanged in shape across the 10.x → 12.x line.

If you haven't read it yet, the previous episode covers how the DI container resolves providers and what "singleton" really means — this article assumes that mental model and builds the module-level picture on top of it, but you don't need it to follow along here.

Here's the setup, trimmed to the part that matters:

TypeScript
1
2
3
4
5
6
7
// notifications.service.ts @Injectable() export class NotificationsService { send(userId: string, message: string) { // ... } }
TypeScript
1
2
3
4
5
// notifications.module.ts @Module({ providers: [NotificationsService], }) export class NotificationsModule {}
TypeScript
1
2
3
4
5
6
7
// orders.module.ts @Module({ imports: [NotificationsModule], controllers: [OrdersController], providers: [OrdersService], }) export class OrdersModule {}
TypeScript
1
2
3
4
5
6
7
8
// orders.service.ts @Injectable() export class OrdersService { constructor( private readonly repo: OrdersRepository, private readonly notifications: NotificationsService, // ← this is what fails ) {} }

Everything here looks correct by the checklist most people carry around: @Injectable() on the service, listed in a module's providers, that module imported by the consumer. And yet Nest still can't resolve it — because the checklist is missing one line: NotificationsModule never says exports: [NotificationsService].

The mental model: a NestJS module isn't an organizational folder — it's a real wall around a slice of the DI container. Each module gets its own injector, and that injector only knows two kinds of providers: the ones listed directly in its own providers array, and the ones explicitly handed to it through imports — and only the subset of those that the imported module put in its own exports array.

providers answers "what can this module build?" exports answers a completely different question: "what am I willing to hand to whoever imports me?" A provider can be fully registered and working inside its own module and still be completely invisible to every other module, because providers alone doesn't open the wall — only exports does.

That's exactly the gap in the bug above: NotificationsModule builds NotificationsService correctly for its own internal use, but never lists it in exports. OrdersModule imports NotificationsModule — establishing an edge in the module graph — but that edge carries nothing across it, because the sending side never put anything on the wire.

TypeScript
1
2
3
4
5
6
// notifications.module.ts — the fix @Module({ providers: [NotificationsService], exports: [NotificationsService], // now it can leave this module }) export class NotificationsModule {}

Key concept: imports opens a door between two modules; exports decides what's allowed to walk through it. A provider needs both — registered somewhere, and exported from wherever it's registered — before another module's injector can see it, no matter how correctly it's decorated.

Runs right in your browser — poke at it and watch the concept react live.

A module with nothing exported is still a perfectly valid, fully functional module — for its own controllers and services:

TypeScript
1
2
3
4
5
@Module({ controllers: [OrdersController], providers: [OrdersService, OrdersRepository], }) export class OrdersModule {}

OrdersController and OrdersService can inject OrdersRepository freely — they're all in the same injector. Nothing here needs an export, because nothing here needs to leave the module. The boundary only becomes visible the moment something outside OrdersModule wants in.

exports takes a subset of what's in providers (or a whole imported module — see the next stage) and makes it resolvable by any module that lists this one in its own imports:

TypeScript
1
2
3
4
5
@Module({ providers: [OrdersService, OrdersRepository], exports: [OrdersService], // OrdersRepository stays private }) export class OrdersModule {}

Only OrdersService is importable from elsewhere. OrdersRepository is an implementation detail — other modules that import OrdersModule get access to the service's public surface, not the repository it happens to use internally. This is deliberate, and it's the same instinct as a private class member: export the smallest set that satisfies every real consumer.

A module can also export a module it imported, without re-listing its individual providers — this passes the whole thing through:

TypeScript
1
2
3
4
5
6
// shared.module.ts @Module({ imports: [CacheModule], exports: [CacheModule], // re-export: anyone importing SharedModule also gets CacheModule's exports }) export class SharedModule {}

Anything that imports SharedModule now has access to whatever CacheModule itself exports, without needing to import CacheModule directly. This is useful for grouping a handful of foundational modules into one convenience import — but it's also a coupling decision: every consumer of SharedModule now implicitly depends on CacheModule's public surface too, even if they only wanted one of the other things SharedModule re-exports.

Key concept: re-exporting is transitive on purpose. Use it to flatten a genuinely related group of modules into one import, not as a way to avoid writing imports: [ModuleA, ModuleB, ModuleC] explicitly when the three aren't actually related.

Some providers really do belong everywhere — a config service, a logger. Re-importing a ConfigModule into every single feature module works, but it's repetitive. @Global() exists for exactly this:

TypeScript
1
2
3
4
5
6
7
// config.module.ts @Global() @Module({ providers: [ConfigService], exports: [ConfigService], }) export class ConfigModule {}

Once ConfigModule is imported once, anywhere in the module tree (typically AppModule), ConfigService becomes resolvable from every other module's injector, with no imports: [ConfigModule] needed at each call site. @Global() doesn't skip registration — the module still has to be imported once, and it still has to export what it wants global — it only skips the repeated importing everywhere else.

This is a real convenience, and it's also a real cost: every module in the app is now implicitly coupled to that provider, with no imports line anywhere marking the dependency. Reading OrdersModule's @Module() decorator no longer tells you everything OrdersService can inject — some of it is invisible, wired in from wherever @Global() was declared. That's fine for two or three truly cross-cutting providers (config, a logger); it stops being fine the moment @Global() becomes the answer to "I don't want to write another import."

forRoot() and forFeature() — the pattern behind ConfigModule.forRoot(...), TypeOrmModule.forFeature([...]), and most third-party Nest modules — are just regular functions that return a module definition object at runtime instead of a static @Module() class body:

TypeScript
1
2
3
4
5
6
7
8
9
10
@Module({}) export class DatabaseModule { static forRoot(options: DatabaseOptions): DynamicModule { return { module: DatabaseModule, providers: [{ provide: 'DB_OPTIONS', useValue: options }, DatabaseService], exports: [DatabaseService], }; } }

Everything from Stages 1–3 applies unchanged: whatever this returns for exports is exactly as visible to importers as a static module's exports array, and whatever it leaves out is exactly as private. The only thing dynamic is when the module shape is decided — at imports: [DatabaseModule.forRoot({ url: '...' })] time, using arguments the static form can't accept — not whether encapsulation applies to the result.

Sometimes two modules genuinely need each other — UsersModule needs PostsModule to look up a user's posts, and PostsModule needs UsersModule to look up a post's author. Importing each other directly deadlocks Nest's bootstrap: to build UsersModule it needs PostsModule fully built first, which needs UsersModule fully built first.

TypeScript
1
2
3
4
5
6
// users.module.ts @Module({ imports: [forwardRef(() => PostsModule)], exports: [UsersService], }) export class UsersModule {}
TypeScript
1
2
3
4
5
6
// posts.module.ts @Module({ imports: [forwardRef(() => UsersModule)], exports: [PostsService], }) export class PostsModule {}

forwardRef() tells Nest "reference this module, but don't try to fully resolve it yet" — it breaks the deadlock by deferring the reference instead of demanding it eagerly. Both sides of a module-level circular dependency need forwardRef(), not just one; wrapping only one side still leaves the other side trying to eagerly resolve something that isn't ready.

The same tool exists at the provider level, for a circular dependency between two services rather than two modules:

TypeScript
1
2
3
4
5
6
7
@Injectable() export class UsersService { constructor( @Inject(forwardRef(() => PostsService)) private readonly posts: PostsService, ) {} }

NestJS's own documentation is explicit that once forwardRef() is involved, the order of instantiation between the two sides is indeterminate — don't write constructor logic that assumes one of them is fully ready before the other's constructor has run.

Key concept: forwardRef() is a real fix for a real, occasional need — but a circular dependency between two modules is usually the container telling you two modules are actually one responsibility split awkwardly in half. Before reaching for forwardRef(), check whether the piece both sides need (in the example above, "which user wrote which post") belongs in a third module that both UsersModule and PostsModule import, with no circle at all.

  • A provider registered but not exported fails with the same error as a provider that was never imported at all. The error message doesn't distinguish "you forgot exports" from "you forgot imports" — check both, every time.
  • Exporting a module you only imported for your own internal use (Stage 3) accidentally makes it part of your public API. If SharedModule re-exports CacheModule just because SharedModule happens to use caching internally, every consumer of SharedModule now depends on CacheModule too — audit exports for things exported by habit rather than by intent.
  • Barrel files (index.ts re-exporting everything in a directory) can create circular imports Nest didn't ask for. Two files that each import from the same barrel, which re-exports both, can produce a TypeScript-level circular import independent of any DI circularity — and it's easy to mistake for a DI problem. NestJS's own circular-dependency guidance calls this out specifically: avoid barrel files for imports within the module you're actively writing.
  • ModuleRef is the escape hatch when forwardRef() feels wrong. Instead of injecting a circular dependency through the constructor, ModuleRef.get() (or .resolve() for scoped providers) can fetch a provider lazily, after the module has finished initializing — useful when the circularity is real but the two sides don't actually need each other at construction time, only later.
  • @Global() providers still have exactly one registration. Making ConfigService global doesn't create one instance per module that uses it — it's the same singleton-per-registration rule from the DI container, just made reachable without an explicit imports line at every call site.

  • Export the smallest surface that satisfies every real consumer — a service, not the repository or config it happens to use internally. Treat exports like a public API, because that's what it is.
  • Reach for @Global() only for genuinely cross-cutting infrastructure (config, logging, a request-tracing context) — not as a shortcut to avoid a couple of imports lines. Every global provider is a dependency grep-ing a module's own file won't show you.
  • Treat a module-level circular dependency as a design smell first, a forwardRef() problem second. Look for the shared responsibility that belongs in a third module before wrapping both sides in forwardRef().
  • When you do need forwardRef(), apply it on both sides of the relationship — module-to-module and, if it's providers, constructor-to-constructor — and never write code that depends on which one finishes initializing first.
  • Avoid barrel-file imports inside the module you're actively editing, to keep TypeScript-level circular imports from masquerading as DI circularity.
  • Re-export a module only when you mean to hand its whole public surface to your own consumers — otherwise import it privately and don't list it in your own exports.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Nest's resolver doesn't distinguish the two causes in its message because, from the injector's point of view, they look identical: the token simply isn't visible in the context it's being asked for. Whether the root cause is a missing imports or a missing exports, check both.

No. exports only changes visibility, not instantiation — the provider is still built once, wherever it's registered in providers. This is the same rule the DI-container episode covers for sharing an instance across modules; exports is the mechanism that makes that sharing possible.

No — a module can only export a provider it registered itself, or a whole module it imported (Stage 3's re-export). You can't export a provider that belongs to some other module without going through that module's own module-level export.

It doesn't change how many instances exist — a global provider still follows the same Scope.DEFAULT/REQUEST/TRANSIENT rules as any other provider. @Global() only changes visibility: every module's injector can resolve it without an explicit imports line.

Yes — the circular dependency is between the two classes, not the modules, so it needs @Inject(forwardRef(() => OtherService)) on both constructors regardless of whether they live in the same @Module() or different ones.

NeedSyntaxNotes
Use a provider only inside its own moduleproviders: [X]No exports needed — nothing outside the module can see it
Make a provider visible to importersproviders: [X], exports: [X]Both lines are required; providers alone never crosses the boundary
Use another module's exported providerimports: [OtherModule]Only pulls in what OtherModule put in its own exports
Pass a whole imported module throughimports: [X], exports: [X] (a module, not a provider)Re-export — your consumers get X's exports too, without importing X directly
Make a provider available everywhere, no repeated imports@Global() on the owning module, still with exports: [X]Import that module once (usually in AppModule); costs an invisible, implicit dependency everywhere else
Configure a module with runtime argumentsstatic forRoot(options): DynamicModule { return { module, providers, exports } }Same encapsulation rules apply to the returned object
Break a module-level circular importimports: [forwardRef(() => OtherModule)] on both modulesOrder of instantiation is indeterminate afterward
Break a provider-level circular dependency@Inject(forwardRef(() => OtherService)) on both constructorsUsually a sign a third, shared module is missing
Resolve a provider without a constructor-time circular importconstructor(private moduleRef: ModuleRef) {} then this.moduleRef.get(X)Fetches lazily, after initialization — sidesteps forwardRef() entirely

  • A NestJS module is a real boundary around its own injector — providers decides what a module can build for itself, exports decides what it's willing to hand to importers, and a provider needs both before another module can inject it.
  • imports opens a connection between two modules; it carries nothing by itself. The provider still has to be exported on the other end.
  • @Global() doesn't remove encapsulation — it removes the need to repeat imports everywhere, at the cost of a dependency that no longer shows up in the consuming module's own file.
  • Dynamic modules (forRoot/forFeature) decide their providers/exports at runtime, but the same visibility rules apply to whatever they return.
  • A circular dependency between two modules usually means a third module is missing more than it means you need forwardRef() — reach for the wrap only after checking for the missing shared piece.

The NotificationsService error from the top of this article is a one-line fix — add exports: [NotificationsService] — but the message Nest prints doesn't tell you which half of the contract is missing. Once "a module is a boundary, and only exports opens it" replaces "it's decorated with @Injectable(), so it should just work," that error stops being a mystery and starts being the first thing you check.

What's the module-boundary bug that cost you the most time — a missing export, an accidental @Global(), or a circular dependency you routed around with forwardRef() instead of fixing? 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:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.