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.

- 1NestJS Request Lifecycle Explained (with Cheat Sheet)13 min
- 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:
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
exportsreally 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.
- The problem: exported everywhere, visible nowhere
- The mental model: a module is a boundary, not a folder
- Stage 1: the smallest module boundary
- Stage 2: exports — what actually crosses the boundary
- Stage 3: re-exporting — passing a module through
- Stage 4:
@Global()— removing the boundary on purpose - Stage 5: dynamic modules follow the same rules
- Stage 6: circular dependencies between modules
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
Here's the setup, trimmed to the part that matters:
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.
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:
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:
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:
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:
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:
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.
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:
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 forgotimports" — 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
SharedModulere-exportsCacheModulejust becauseSharedModulehappens to use caching internally, every consumer ofSharedModulenow depends onCacheModuletoo — auditexportsfor things exported by habit rather than by intent. - Barrel files (
index.tsre-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. ModuleRefis the escape hatch whenforwardRef()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. MakingConfigServiceglobal 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 explicitimportsline 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
exportslike 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 ofimportslines. Every global provider is a dependencygrep-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 inforwardRef(). - 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.
Think it clicked? Take the 9-question quiz →
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.
| Need | Syntax | Notes |
|---|---|---|
| Use a provider only inside its own module | providers: [X] | No exports needed — nothing outside the module can see it |
| Make a provider visible to importers | providers: [X], exports: [X] | Both lines are required; providers alone never crosses the boundary |
| Use another module's exported provider | imports: [OtherModule] | Only pulls in what OtherModule put in its own exports |
| Pass a whole imported module through | imports: [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 arguments | static forRoot(options): DynamicModule { return { module, providers, exports } } | Same encapsulation rules apply to the returned object |
| Break a module-level circular import | imports: [forwardRef(() => OtherModule)] on both modules | Order of instantiation is indeterminate afterward |
| Break a provider-level circular dependency | @Inject(forwardRef(() => OtherService)) on both constructors | Usually a sign a third, shared module is missing |
| Resolve a provider without a constructor-time circular import | constructor(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 —
providersdecides what a module can build for itself,exportsdecides what it's willing to hand to importers, and a provider needs both before another module can inject it. importsopens 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 repeatimportseverywhere, at the cost of a dependency that no longer shows up in the consuming module's own file.- Dynamic modules (
forRoot/forFeature) decide theirproviders/exportsat 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:
- ⭐ 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___
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.