Feature Modules
As an application grows, dumping every controller and provider into the root AppModule quickly becomes unmanageable. NestJS encourages you to slice the codebase along business domains — users, orders, billing — and give each one its own feature module. A feature module is a self-contained unit that bundles the controllers, providers, and any sub-imports for a single area of responsibility, then exposes only what the rest of the app needs. This is the foundation of separation of concerns in Nest, and it scales from a handful of routes to hundreds.
What a feature module is
A feature module is just a class decorated with @Module() that groups everything belonging to one domain. The four metadata keys you will use are controllers, providers, imports, and exports. The critical idea is encapsulation: a provider declared in a module is private to that module unless you explicitly list it in exports. Other modules can only consume a provider after the owning module exports it and the consuming module imports the owning module.
| Key | Purpose | Visibility effect |
|---|---|---|
controllers | HTTP route handlers owned by this module | Always active when the module is loaded |
providers | Services, repositories, factories for DI | Private to this module by default |
imports | Other modules whose exports this module needs | Brings in their exported providers |
exports | Subset of providers made available to importers | Makes a provider public |
Generating a feature module
The Nest CLI scaffolds a module, controller, and service in one command and automatically wires the new module into AppModule for you.
nest generate module users
nest generate controller users
nest generate service users
Output:
CREATE src/users/users.module.ts (82 bytes)
CREATE src/users/users.controller.ts (99 bytes)
CREATE src/users/users.controller.spec.ts (478 bytes)
CREATE src/users/users.service.ts (89 bytes)
UPDATE src/app.module.ts (312 bytes)
The generated UsersModule registers its own controller and service. Note that UsersService is listed in providers but not in exports, so it stays private to the users domain.
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Here exports: [UsersService] is the deliberate choice that lets other domains — like orders — depend on user data without reaching into the users module’s internals.
Building the users domain
A realistic service holds the domain logic and data access. Keep it framework-light so it can be tested in isolation.
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
export interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private readonly users: User[] = [
{ id: 1, name: 'Ada Lovelace', email: '[email protected]' },
{ id: 2, name: 'Alan Turing', email: '[email protected]' },
];
findOne(id: number): User {
const user = this.users.find((u) => u.id === id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
}
// src/users/users.controller.ts
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { UsersService, User } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): User {
return this.usersService.findOne(id);
}
}
Composing modules through imports and exports
The orders domain needs to validate that a user exists before creating an order. Instead of duplicating user logic, OrdersModule imports UsersModule and injects the exported UsersService directly.
// src/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
export interface Order {
id: number;
userId: number;
total: number;
}
@Injectable()
export class OrdersService {
private orders: Order[] = [];
constructor(private readonly usersService: UsersService) {}
create(userId: number, total: number): Order {
// Throws NotFoundException if the user does not exist.
this.usersService.findOne(userId);
const order: Order = { id: this.orders.length + 1, userId, total };
this.orders.push(order);
return order;
}
}
// src/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { UsersModule } from '../users/users.module';
@Module({
imports: [UsersModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
Because UsersModule exports UsersService and OrdersModule imports UsersModule, Nest’s dependency injection can satisfy the UsersService parameter in OrdersService’s constructor.
If you inject a provider that the owning module did not export, Nest fails at startup with a
Nest can't resolve dependencieserror. The fix is almost always to add the provider to the owning module’sexportsarray — not to re-declare it in the consuming module, which would create a second, unrelated instance.
Wiring everything into the root module
The root AppModule becomes thin: it simply composes the feature modules. It declares no domain controllers or providers of its own.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [UsersModule, OrdersModule],
})
export class AppModule {}
A request to POST /orders for a non-existent user now surfaces the users domain’s own error, proving the modules are correctly composed.
Output:
$ curl -s -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"userId":99,"total":50}'
{"statusCode":404,"message":"User 99 not found","error":"Not Found"}
Best Practices
- Organize one module per business domain (
users,orders,billing) and keep each module’s files in a dedicated folder. - Export only the providers other modules genuinely need; everything else should stay private to enforce real encapsulation.
- Compose feature modules in the root
AppModuleviaimportsand keep the root module free of domain controllers and providers. - Import the module, not the provider — never re-list another module’s service in your own
providers, or you will get a duplicate instance. - When two feature modules need each other, use
forwardRef()to resolve the circular dependency rather than merging the domains. - Promote shared, app-wide providers (config, logging) into a dedicated module and mark it
@Global()instead of re-importing it everywhere.