NestJS – Modules

July 16, 20268 min readUpdated 9/5/2026

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.