NestJS is opinionated. Modules, controllers, services, providers. Its Angular for the backend.
But Ive seen plenty of NestJS apps turn into spaghetti. Just because you have modules doesnt mean your architecture is clean.
The Module Structure
Rule: Feature modules should be independent. If UsersModule needs OrdersModule needs UsersModule, you have a problem.
Folder Structure That Works
src/
├── modules/
│ ├── users/
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── users.repository.ts
│ │ ├── dto/
│ │ │ ├── create-user.dto.ts
│ │ │ └── update-user.dto.ts
│ │ ├── entities/
│ │ │ └── user.entity.ts
│ │ └── interfaces/
│ │ └── user.interface.ts
│ ├── orders/
│ │ └── ...
│ └── products/
│ └── ...
├── common/
│ ├── decorators/
│ ├── filters/
│ ├── guards/
│ ├── interceptors/
│ └── pipes/
├── config/
│ └── configuration.ts
└── main.ts
Keep related code together. Dont scatter DTOs, entities, and services across the project.
Controllers: Keep Them Thin
Controllers handle HTTP. Thats it. No business logic.
// ❌ Bad: Business logic in controller
@Controller('orders')
export class OrdersController {
@Post()
async create(@Body() dto: CreateOrderDto) {
// Validation, calculations, database calls...
const total = dto.items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
const order = await this.orderRepository.save({
...dto,
total,
status: 'pending'
});
await this.emailService.send(dto.userId, 'Order created');
return order;
}
}
// ✅ Good: Controller delegates to service
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
async create(@Body() dto: CreateOrderDto) {
return this.ordersService.create(dto);
}
}
Services: Business Logic Lives Here
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly emailService: EmailService,
private readonly inventoryService: InventoryService,
) {}
async create(dto: CreateOrderDto): Promise<Order> {
// Check inventory
await this.inventoryService.checkAvailability(dto.items);
// Calculate total
const total = this.calculateTotal(dto.items);
// Create order
const order = await this.ordersRepository.create({
...dto,
total,
status: OrderStatus.PENDING,
});
// Send confirmation
await this.emailService.sendOrderConfirmation(order);
return order;
}
private calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
Repository Pattern
Separate data access from business logic:
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User)
private readonly repo: Repository<User>,
) {}
async findById(id: string): Promise<User | null> {
return this.repo.findOne({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.repo.findOne({ where: { email } });
}
async create(data: CreateUserData): Promise<User> {
const user = this.repo.create(data);
return this.repo.save(user);
}
async update(id: string, data: UpdateUserData): Promise<User> {
await this.repo.update(id, data);
return this.findById(id);
}
}
Why? Services can be tested without a database. Repositories can be mocked.
DTOs and Validation
Use class-validator for automatic validation:
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
@IsOptional()
name?: string;
}
// Enable validation globally
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw on unknown properties
transform: true, // Transform to DTO class
}));
Guards for Authentication
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token);
request.user = payload;
return true;
} catch {
throw new UnauthorizedException();
}
}
private extractToken(request: Request): string | null {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : null;
}
}
// Use it
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrdersController {
// All routes require authentication
}
// Or globally
app.useGlobalGuards(new JwtAuthGuard());
Interceptors for Cross-Cutting Concerns
// Logging interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
this.logger.log(`${method} ${url} - ${duration}ms`);
}),
);
}
}
// Transform response interceptor
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
Exception Filters
Handle errors consistently:
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('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 message = exception instanceof HttpException
? exception.message
: 'Internal server error';
this.logger.error(`${request.method} ${request.url}`, exception);
response.status(status).json({
success: false,
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Configuration Management
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 5432,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '1d',
},
});
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
],
})
export class AppModule {}
// Using config
@Injectable()
export class AuthService {
constructor(private readonly configService: ConfigService) {}
getJwtSecret(): string {
return this.configService.get<string>('jwt.secret');
}
}
The Request Flow
Testing Strategy
describe('UsersService', () => {
let service: UsersService;
let repository: MockType<UsersRepository>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UsersService,
{
provide: UsersRepository,
useFactory: () => ({
findById: jest.fn(),
create: jest.fn(),
}),
},
],
}).compile();
service = module.get(UsersService);
repository = module.get(UsersRepository);
});
it('should create a user', async () => {
const dto = { email: 'test@test.com', password: 'password123' };
const expected = { id: '1', ...dto };
repository.create.mockResolvedValue(expected);
const result = await service.create(dto);
expect(result).toEqual(expected);
expect(repository.create).toHaveBeenCalledWith(dto);
});
});
Quick Checklist
- [ ] Feature modules are independent
- [ ] Controllers are thin (no business logic)
- [ ] Services handle business logic
- [ ] Repositories handle data access
- [ ] DTOs with validation
- [ ] Global exception filter
- [ ] Configuration management
- [ ] Guards for authentication
- [ ] Interceptors for cross-cutting concerns
Further Reading
NestJS gives you structure. Its up to you to use it well. Keep modules independent, controllers thin, and services focused. Your future self will thank you.
