Module & Encapsulation Pattern
In NestJS, a module is not just a packaging convenience — it is a bounded context with a private interior and an explicit public API. Each @Module owns a slice of functionality, hides its internal providers by default, and exposes only what it deliberately exports. Treating modules this way keeps coupling under control, makes features independently testable, and turns the dependency graph into something you can reason about as the application grows.
Why encapsulation matters
By default, a provider registered in a module is private to that module. Nothing outside can inject it. This is the single most important fact about NestJS modules: visibility is opt-in. A provider becomes part of a module’s public surface only when you list it in exports. Everything else is an implementation detail you are free to refactor without breaking consumers.
This default flips the usual problem on its head. Instead of trying to hide things, you start fully encapsulated and reveal the minimum. The result is that the exports array of each module reads like a contract — the only symbols other features are allowed to depend on.
| Module member | Default visibility | How to expose |
|---|---|---|
providers | Private to the module | Add to exports |
controllers | Bound to the module’s routes | Not exportable (always local) |
imports | Brings in other modules’ exports | Re-export via exports |
exports | The module’s public API | — |
A feature module as a bounded context
Consider a users feature. The repository and a password hasher are internal mechanics; only the UsersService should be visible to the rest of the app.
// users/users.service.ts
import { Injectable } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { PasswordHasher } from './password-hasher';
@Injectable()
export class UsersService {
constructor(
private readonly repo: UsersRepository,
private readonly hasher: PasswordHasher,
) {}
async register(email: string, password: string) {
const passwordHash = await this.hasher.hash(password);
return this.repo.create({ email, passwordHash });
}
findByEmail(email: string) {
return this.repo.findByEmail(email);
}
}
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { PasswordHasher } from './password-hasher';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService, UsersRepository, PasswordHasher],
exports: [UsersService], // public API — repo + hasher stay private
})
export class UsersModule {}
Now another feature can consume UsersService only by importing UsersModule:
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [UsersModule], // gains access to UsersService, nothing else
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
If AuthService tries to inject UsersRepository, the container throws — proving the boundary holds.
Output:
[Nest] ERROR [ExceptionHandler] Nest can't resolve dependencies of the AuthService (?).
Please make sure that the argument UsersRepository at index [0] is available in the AuthModule context.
Potential solutions:
- Is UsersModule a valid Nest module?
- If UsersRepository is a provider, is it part of the current AuthModule?
- If UsersRepository is exported from a separate @Module, is that module imported within AuthModule?
Re-exporting to compose APIs
A module can expose providers that originate in a module it imports. This lets an aggregating module act as a single facade over several internal modules.
// core/core.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { LoggerModule } from './logger/logger.module';
@Module({
imports: [ConfigModule, LoggerModule],
exports: [ConfigModule, LoggerModule], // re-export the whole API surface
})
export class CoreModule {}
Any module that imports CoreModule now sees everything ConfigModule and LoggerModule export, without naming them individually.
Tip: Re-export entire modules rather than re-listing their providers. If the underlying module changes its exports, your facade stays correct automatically.
Shared modules vs. global modules
A shared module is just a normal module whose exports are imported wherever needed — and NestJS returns the same singleton instance to every importer, so state is shared safely. For genuinely cross-cutting concerns you can mark a module @Global(), registering its exports app-wide after a single import.
import { Global, Module } from '@nestjs/common';
import { LoggerService } from './logger.service';
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
Warning: Reach for
@Global()sparingly. It bypasses explicit imports, which is exactly what makes module boundaries auditable. Overusing it recreates the global-state problems modules exist to prevent.
Best practices
- Keep providers private by default; add to
exportsonly when another module genuinely needs them. - Let each module map to one feature or bounded context — one cohesive reason to change.
- Treat the
exportsarray as a published contract and review changes to it as carefully as a public interface. - Prefer re-exporting whole modules over re-listing individual providers in facade modules.
- Avoid circular imports between feature modules; if two modules need each other, extract the shared piece into a third module.
- Reserve
@Global()for true infrastructure (logging, config) and import everything else explicitly. - Co-locate a module’s controllers, services, and internals in one directory so the boundary is visible in the file tree.