What is NestJS?
NestJS is a progressive Node.js framework that brings structure to server-side JavaScript. Where Express gives you primitives and lets you choose, NestJS gives you an architecture: modules, controllers, providers, decorators and a dependency injection container. It borrows heavily from Angular and from enterprise Java frameworks, then runs on top of either Express or Fastify.
That opinionated stance is the entire value proposition. On a five-person team building a large API, the hardest problems are rarely HTTP. They are naming, boundaries, testing and keeping patterns consistent as the codebase grows. NestJS answers those with conventions you either accept or replace, and it ships the plumbing — validation, guards, interceptors, configuration, testing utilities — so you do not assemble it yourself.
Modules define the boundaries
Everything in NestJS is organised into modules. A module is a class decorated with @Module that declares its controllers, its providers and what it exports to the rest of the application.
@Module({
imports: [DatabaseModule],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
export class CatsModule {}
imports brings in other modules whose exported providers you need. providers are the classes this module owns. exports is the public API: only what a module exports can be injected elsewhere. This makes dependencies explicit — if OrdersModule needs CatsService, it must import CatsModule, and the compiler-like container will tell you when it cannot resolve something.
The root AppModule imports every feature module. Feature modules can be grouped into shared or core modules, and @Global() marks a module that should be available everywhere without repeated imports. Use global modules sparingly for genuinely cross-cutting infrastructure such as configuration or logging.
Controllers: routing as decorators
A controller maps HTTP requests to methods. The class decorator sets the base path, and method decorators set the verb and sub-path.
@Controller("cats")
export class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
findAll() {
return this.catsService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.catsService.findOne(id);
}
@Post()
create(@Body() dto: CreateCatDto) {
return this.catsService.create(dto);
}
}
Parameter decorators do the extraction: @Param, @Query, @Body, @Headers, @Req and @Res. The handler returns a plain value or a promise, and NestJS serialises it to JSON with the right status. Throw an HttpException such as NotFoundException and the framework turns it into a proper error response.
Controllers should be thin. They translate HTTP into a method call and nothing more. All logic belongs in providers, which keeps the controller testable and the HTTP concerns isolated.
Providers and dependency injection
A provider is any class that can be injected. Mark it @Injectable() and declare it in a module, and the container handles the rest.
@Injectable()
export class CatsService {
constructor(private readonly db: DatabaseService) {}
findAll() {
return this.db.query("SELECT * FROM cats");
}
}
Injection is by constructor and by type. The container reads the emitted metadata, resolves each parameter and passes the instance in. You can also inject by token when the value is not a class, using @Inject("CONFIG") with a matching provider.
Providers can be customised with factories, values and aliases. A factory provider is the usual way to inject something that needs async setup, such as a database connection:
@Module({
providers: [
{
provide: "DATABASE",
useFactory: async (config: ConfigService) => {
return createPool(config.getOrThrow("DATABASE_URL"));
},
inject: [ConfigService],
},
],
exports: ["DATABASE"],
})
export class DatabaseModule {}
Scope matters too. The default singleton scope shares one instance across the app, which is what you want for stateless services. REQUEST scope creates a new instance per request and is useful for request-specific context, but it costs performance because it bubbles up the dependency graph.
DTOs, pipes and validation
Input validation is a first-class concern. A DTO (data transfer object) is a class that describes the shape of a request, decorated with class-validator rules.
export class CreateCatDto {
@IsString()
@MinLength(1)
name: string;
@IsInt()
@Min(0)
age: number;
}
A pipe transforms or validates the value before it reaches the handler. Enable the built-in ValidationPipe globally and every decorated DTO is checked automatically:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
whitelist strips unknown properties, forbidNonWhitelisted rejects them outright, and transform converts payloads into actual DTO instances so types are real at runtime. Invalid input produces a structured 400 without a single line of validation code in the handler. Pipes can also be applied per-parameter or per-controller with @UsePipes.
Guards, interceptors and filters
These three building blocks cover most cross-cutting concerns, and they run in a fixed order.
Guards run before the handler and decide whether the request proceeds.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>("roles", context.getHandler());
if (!roles) return true;
const { user } = context.switchToHttp().getRequest();
return roles.includes(user?.role);
}
}
Interceptors wrap the handler call and can transform the result, measure time or add caching.
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now();
return next.handle().pipe(
tap(() => console.log(`took ${Date.now() - start}ms`)),
);
}
}
Exception filters catch thrown errors and format the response. Without one, NestJS uses a sensible default; a custom filter keeps error shapes consistent across the API.
@Catch(HttpException)
export class HttpErrorFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}
Register them globally, per controller or per route. Global guards, interceptors and filters are how a mature NestJS app centralises authentication, observability and error handling.
Configuration and environment
The @nestjs/config package wraps environment variables and makes them injectable. Import it once as a global module.
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
})
export class AppModule {}
Then inject ConfigService wherever it is needed:
@Injectable()
export class DatabaseService {
constructor(private readonly config: ConfigService) {}
connect() {
const url = this.config.getOrThrow<string>("DATABASE_URL");
return createPool(url);
}
}
Prefer getOrThrow for required values so a missing variable fails at startup rather than at the first request. Combine this with a validation schema so configuration is checked once, and keep secrets out of the repository entirely.
Testing with TestingModule
NestJS’s testing utilities build a real container in isolation. You declare only the providers under test, then override their dependencies with mocks.
describe("CatsController", () => {
let controller: CatsController;
let service: CatsService;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();
controller = module.get(CatsController);
service = module.get(CatsService);
});
it("returns all cats", () => {
jest.spyOn(service, "findAll").mockReturnValue([]);
expect(controller.findAll()).toEqual([]);
});
});
For end-to-end tests, build the full app and use supertest against app.getHttpServer(). Because the DI container is the same one production uses, these tests exercise guards, pipes and filters exactly as deployed.
Best practices
- Keep controllers thin and put all logic in injectable services.
- Draw module boundaries around features, and export only what other modules need.
- Validate every request with a DTO and a global
ValidationPipe. - Use guards for authentication and authorisation, interceptors for response shaping.
- Register global exception filters so error responses share one shape.
- Inject configuration instead of reading
process.envdirectly. - Prefer singleton scope; reach for request scope only when you truly need it.
- Test with
TestingModuleand override dependencies rather than hitting real services.
Common mistakes
- Constructing providers with
newinstead of injecting them, which breaks the container. - Forgetting to add a provider to a module, then chasing a “Nest can’t resolve dependencies” error.
- Importing a module but forgetting to
exportthe provider it should share. - Putting business logic in controllers and making them impossible to unit test.
- Using request scope everywhere and silently degrading performance.
- Trusting
@Body()without a DTO and a validation pipe. - Scattering error formatting instead of centralising it in a filter.
- Treating modules as folders rather than as dependency boundaries.
Where to go next
NestJS is the most structured way to build a Node.js backend, and it sits directly on top of Express or Fastify. Read those guides to understand the HTTP layer you are abstracting. Strong TypeScript is essential because decorators and dependency injection lean on types, and the Node.js basics explain the runtime everything runs on. Then build one feature module end to end: controller, service, DTO and test.