A module is the unit Nest is organised around. It is a class with a decorator and no body, and its entire job is to declare what belongs together and what is visible outside.
@Module({
imports: [TypeOrmModule.forFeature([Project, Quote, ServiceCategory])],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}Four keys, and each answers a different question.
The four keys
controllers — the classes in this module that handle routes. A
controller not listed here does not exist as far as Nest is concerned: no error, no warning, just
a route that 404s and never appears in the startup log. This is the single most common "why
doesn't my endpoint work" in Nest, and the startup log is where you catch it.
providers — the injectable classes this module owns. Services,
guards, custom providers. Listing a class here makes it constructible inside this module
and nowhere else.
imports — other modules whose exports this module wants to use.
Importing a module gives you its exports, not its providers.
exports — which of this module's providers other modules may
inject. Everything else stays private.
Providers are private by default
This is the rule that surprises people coming from Express, where an import is an import.
QuotesService needs to ask whether a project still accepts bids. That logic lives
in ProjectsService, in a different module. Importing the class directly and calling
new ProjectsService() would compile — and would produce a second instance with no
database connection injected into it.
The correct version is two declarations. ProjectsModule exports its service:
@Module({
imports: [TypeOrmModule.forFeature([Project, Quote, ServiceCategory])],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}and QuotesModule imports the module:
@Module({
imports: [TypeOrmModule.forFeature([Quote, ContractorProfile]), ProjectsModule],
controllers: [QuotesController],
providers: [QuotesService],
})
export class QuotesModule {}Now QuotesService can take a ProjectsService in its constructor and
receive the same singleton the projects module uses. Remove the exports line and the
application refuses to start:
Nest can't resolve dependencies of the QuotesService (DataSource, ?).
Please make sure that the argument ProjectsService at index [1] is
available in the QuotesModule context.Read that message carefully once and it becomes easy. ? marks the parameter it
could not resolve, the index tells you which constructor argument, and "available in the
X context" is the important half — the class exists, it is simply not visible from
here.
The privacy is worth the ceremony. A module's provider list is a complete statement of what it owns, and its exports are a deliberate public interface. In an Express codebase any file can import any other, and after two years nothing has a boundary.
Feature modules and the root
One module per area of the domain, all assembled by the root:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [loadConfig],
envFilePath: ['.env'],
}),
TypeOrmModule.forRoot(buildDataSourceOptions()),
AuthModule,
ContractorsModule,
ProjectsModule,
QuotesModule,
ReviewsModule,
],
// ...
})Notice there is nothing else in the root — no controllers, no services. The root module's job is composition. When it starts accumulating providers of its own, that is a sign a feature module is missing.
A module is a singleton, however many times it is imported
ContractorProfile's repository is registered by three different modules, and
ProjectsModule is imported by QuotesModule while also being listed in
the root. None of that creates duplicates.
Nest instantiates each module once per application and caches it. Import the same module from
five places and all five receive the same instance, with the same provider instances inside it.
That is what makes it safe for QuotesService and ProjectsController to
share a ProjectsService that holds a database connection: there is only ever one.
It is also the thing to remember when you put state in a provider. A field on a service is application-wide and shared by every concurrent request, because the service is a singleton. Most of the time that is what you want — a connection pool, a cache, a compiled configuration object. Occasionally it is a bug you will spend an afternoon on, and the fix is either not to hold the state or to change the provider's scope, which lesson 5 covers.
Dynamic modules: forRoot and forFeature
Two of those imports are method calls rather than plain classes, and that difference matters.
A plain ProjectsModule is fully described by its own decorator. But
TypeOrmModule cannot be — it has no idea which database to connect to until you tell
it. So it exposes a static method that returns a module definition built from arguments. That is a
dynamic module, and the naming convention is consistent across the ecosystem:
forRoot(options) configures the thing once, globally. One database connection, one
config source. Called in the root module.
forFeature(entities) registers a slice of an already-configured thing for one
module. TypeOrmModule.forFeature([Quote, ContractorProfile]) does not open a second
connection — it makes the repositories for those two entities injectable inside
QuotesModule. It is called once per feature module, listing only the entities that
module touches.
That per-module list is real documentation. Reading ReviewsModule's
forFeature tells you a review write touches four tables before you open the
service.
There is also forRootAsync / registerAsync, for when the options
themselves need something injected —
lesson 5 covers it.
@Global, and why it is used once
Every feature module in this application uses JwtAuthGuard. A guard is a provider,
and a provider can only be constructed if its dependencies are available in the module that
declares it — so every one of the five feature modules would have to import
AuthModule purely to satisfy the container.
@Global() removes that:
@Global()
@Module({
imports: [
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<AppConfig>) => {
const jwt = config.getOrThrow<AppConfig['jwt']>('jwt')
return {
secret: jwt.secret,
signOptions: {
algorithm: 'HS256',
expiresIn: jwt.expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
verifyOptions: { algorithms: ['HS256'] },
}
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtModule, JwtAuthGuard, RolesGuard],
})
export class AuthModule {}A global module's exports are available everywhere without being imported. It still has to be imported once — by the root — to be instantiated at all.
The reason it appears exactly once in this codebase is that @Global destroys the
property that makes modules useful. A module's imports are a statement of its dependencies, and a
global provider is a dependency that does not appear in any of them. Use it for a genuinely
ambient cross-cutting concern, which authentication is, and for nothing else. Two or three global
modules and the dependency graph has stopped being readable.
Note JwtModule in the exports as well. JwtAuthGuard is exported, but a
guard constructed in another module still needs JwtService to be resolvable there —
so the module that provides it gets re-exported too. Re-exporting an imported module is a normal
and useful thing to do.
What modules buy you in tests
The boundary is not only an organising device. It is what makes a Nest application testable without a running server, because the testing utilities take a module definition and build the same graph:
const moduleRef = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile()
guard = moduleRef.get(RolesGuard)That is a two-provider application. It has no controllers, no database and no HTTP server, and
RolesGuard cannot tell the difference — it is constructed exactly as it would be in
production, by the same container.
The same mechanism scales the other way. An end-to-end test imports the real root module and gets the entire application:
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
app = moduleRef.createNestApplication()Between those two extremes you can import the real module and replace one provider inside it. That range — from two classes to the whole system, with the seam anywhere you like — exists because every dependency is declared rather than imported. Lesson 19 is entirely about it.
Circular imports
Two modules that need each other will not resolve, and Nest says so at startup. There is an escape hatch:
// A deliberate cycle, kept only when the alternative is worse.
@Module({
imports: [forwardRef(() => QuotesModule)],
})
export class ProjectsModule {}It works, and it is almost always a signal rather than a solution. This application has no
forwardRef anywhere: QuotesModule imports ProjectsModule
and not the reverse, because a quote is written against a project and a project knows nothing
about who might bid on it. When you reach for forwardRef, look first for the shared
thing both modules actually want, which usually belongs in a third module.
How big should a module be?
The useful test is not size but cohesion: a module should own a set of rules that change
together. This application has one per noun in the domain — projects, quotes, reviews, contractors,
auth — and each one owns rules that genuinely belong to it. The rule about which trades a
contractor may bid in lives in QuotesModule, because it constrains a quote. The rule
about which status transitions are legal lives in ProjectsModule, because it
constrains a project.
Two shapes suggest the split is wrong. A module that imports six others and exports nothing is
usually a coordinator that should have been a service inside one of them. And a module that two
others both reach into for different halves of its behaviour is usually two modules that have not
been separated yet — the giveaway is an exports array whose entries have no obvious
relationship to each other.
Resist splitting purely by technical layer. A ServicesModule and a
ControllersModule would satisfy every rule in this lesson and tell a reader nothing,
because knowing that something is a service is not knowledge — knowing that it is how a quote gets
accepted is.
One more shape worth naming: a module that exists only to hold things other modules use — a
CommonModule or SharedModule. It is fine when its contents genuinely
belong together, and a warning sign when it becomes the place anything reusable is put, because
then every module imports it and the dependency graph says nothing. Prefer moving a shared thing
into the module that owns the concept, and reach for a shared module only when no single owner
exists.
What to take away
A module is a visibility boundary, not a folder. Providers are private until exported, imports bring in another module's exports rather than its internals, and the error message when you get it wrong names both the missing dependency and the context it was missing from.
Next: controllers, which are the classes in that
controllers array and the only place in a well-organised Nest application that knows
anything about HTTP.