NestJS is a progressive Node.js framework that offers a structured architecture for building efficient and scalable server-side applications. Leveraging TypeScript by default, it integrates seamlessly with modern JavaScript features and design patterns. In this article, we’ll explore best practices for structuring a NestJS application to maintain scalability, readability, and maintainability.
1. Keep Your Directory Structure Organized
A well-organized directory structure is crucial for maintaining code clarity. A common structure includes separate directories for modules, controllers, services, and other components.
Here’s an example directory structure:
src/
├── modules/
│ ├── users/
│ │ ├── dto/
│ │ ├── users.controller.ts
│ │ ├── users.module.ts
│ │ ├── users.service.ts
│ ├── auth/
│ │ ├── dto/
│ │ ├── auth.controller.ts
│ │ ├── auth.module.ts
│ │ ├── auth.service.ts
├── common/
│ ├── filters/
│ ├── interceptors/
│ ├── guards/
│ ├── pipes/
├── main.ts
└── app.module.ts
2. Use Descriptive and Consistent File Naming Conventions
File names should be descriptive and consistent to make identification easy. The names should indicate their purpose or their content.
# Examples
users.controller.ts
users.module.ts
users.service.ts
create-user.dto.ts
update-user.dto.ts
3. Use Modules to Encapsulate Features
Modules are the basic building blocks of a NestJS application. Group related controllers and providers (services, guards, etc.) into modules. This encapsulation increases modularity and ease of maintenance.
For example, a Users module might look like this:
// 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 {}
4. Utilize Dependency Injection
NestJS’s dependency injection system helps manage the lifecycle of objects and their dependencies. It ensures that components like services and controllers are reusable and easily testable.
Example of dependency injection in a service:
// users.service.ts
import { Injectable } from '@nestjs/common';
import { User } from './user.entity'; // hypothetical entity
@Injectable()
export class UsersService {
private users: User[] = [];
createUser(user: User) {
this.users.push(user);
}
findAll(): User[] {
return this.users;
}
}
And in a controller:
// users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './user.entity'; // hypothetical entity
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() user: User) {
this.usersService.createUser(user);
}
@Get()
findAll(): User[] {
return this.usersService.findAll();
}
}
5. Use DTOs for Data Validation and Transformation
Data Transfer Objects (DTOs) are objects that carry data between processes. They ensure that the data sent by end-users is correct and secure. Use classes to define DTOs and leverage libraries like class-transformer
and class-validator
for validation.
Example of a User DTO:
// dto/create-user.dto.ts
import { IsString, IsEmail, IsNotEmpty } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
readonly name: string;
@IsEmail()
@IsNotEmpty()
readonly email: string;
}
In a controller:
// users.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
this.usersService.createUser(createUserDto);
}
}
6. Implement Global Error Handling
Use exception filters to manage exceptions in a consistent way across your application. This approach ensures that your application responses are predictable and user-friendly.
Example of a global exception filter:
// common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
}
Register it in the main.ts
:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
Conclusion
Structuring a NestJS application following best practices ensures maintainability, scalability, and readability. By keeping an organized directory structure, adhering to naming conventions, encapsulating features into modules, utilizing dependency injection, validating data with DTOs, and implementing global error handling, you can streamline development and foster collaboration within your team.
Remember, the key to a robust NestJS application is to continually refine and adapt your structure and practices as your project evolves. Happy coding!