After building backend services with Express for years, switching to NestJS changed how I think about Node.js architecture. This isn’t a getting-started tutorial — it’s the patterns and decisions I’ve made building production APIs at W3cert.
Why NestJS Over Express?
Express is great for small services, but it gives you zero opinions about structure. Every Express project I’ve worked on older than 6 months had a different folder structure, different patterns for DI, and different ways of handling middleware.
NestJS solves this with opinionated architecture inspired by Angular:
- Modules group related functionality
- Dependency injection is built-in, not bolted on
- Decorators replace boilerplate
- Everything is TypeScript-first
Project Structure That Scales
Here’s the structure I use for every new NestJS service:
src/
├── modules/
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── guards/
│ │ │ └── jwt-auth.guard.ts
│ │ ├── strategies/
│ │ │ └── jwt.strategy.ts
│ │ └── dto/
│ │ ├── login.dto.ts
│ │ └── register.dto.ts
│ ├── users/
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── entities/
│ │ │ └── user.entity.ts
│ │ └── dto/
│ │ └── create-user.dto.ts
│ └── ...
├── common/
│ ├── filters/
│ │ └── http-exception.filter.ts
│ ├── interceptors/
│ │ └── transform.interceptor.ts
│ ├── pipes/
│ │ └── validation.pipe.ts
│ └── decorators/
│ └── current-user.decorator.ts
├── config/
│ ├── database.config.ts
│ └── app.config.ts
├── app.module.ts
└── main.ts
The key principle: each module is self-contained. You should be able to delete a module folder and only need to remove its import from app.module.ts.
Dependency Injection Done Right
The most powerful feature of NestJS is its DI container. Here’s a real-world example — a service that depends on a database repository and a cache layer:
@Injectable()
export class ProductService {
constructor(
@InjectRepository(Product)
private readonly productRepo: Repository<Product>,
private readonly cacheService: CacheService,
private readonly eventEmitter: EventEmitter2,
) {}
async findById(id: string): Promise<Product> {
const cached = await this.cacheService.get(`product:${id}`);
if (cached) return cached;
const product = await this.productRepo.findOneOrFail({
where: { id },
relations: ['category', 'variants'],
});
await this.cacheService.set(`product:${id}`, product, 3600);
return product;
}
async create(dto: CreateProductDto): Promise<Product> {
const product = this.productRepo.create(dto);
const saved = await this.productRepo.save(product);
this.eventEmitter.emit('product.created', saved);
return saved;
}
}
Notice how CacheService and EventEmitter2 are injected automatically. No manual wiring. No factories. NestJS resolves the dependency graph for you.
Global Exception Handling
Every API needs consistent error responses. Instead of try-catch blocks everywhere, use a global exception filter:
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
message = typeof res === 'string' ? res : (res as any).message;
}
response.status(status).json({
success: false,
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}
Register it globally in main.ts:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}));
app.enableCors();
await app.listen(3000);
}
bootstrap();
Request Validation with DTOs
NestJS + class-validator makes input validation declarative:
export class CreateProductDto {
@IsString()
@MinLength(3)
@MaxLength(255)
name: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
price: number;
@IsUUID()
categoryId: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
Invalid requests get rejected before they hit your service layer. The error response includes exactly which fields failed and why.
Database Migrations
Never use synchronize: true in production. Use migrations:
# Generate a migration from entity changes
npx typeorm migration:generate -d src/config/data-source.ts src/migrations/AddProductTable
# Run pending migrations
npx typeorm migration:run -d src/config/data-source.ts
# Revert the last migration
npx typeorm migration:revert -d src/config/data-source.ts
I keep my data-source.ts separate from the NestJS config so the TypeORM CLI can use it directly.
Performance Tips
After running NestJS in production for over a year, here are the optimizations that made the biggest difference:
- Use
@nestjs/cache-managerwith Redis for frequently accessed data - Enable compression with
@nestjs/platform-expressandcompressionmiddleware - Use database indexes — NestJS won’t save you from slow queries
- Implement pagination on every list endpoint from day one
- Use
selectin TypeORM queries — don’t fetch entire entities when you need 3 fields
Wrapping Up
NestJS isn’t perfect — the learning curve is steep if you’re coming from Express, and the decorator magic can feel heavy. But for any service that’s going to grow beyond a few endpoints, the structure it enforces pays for itself within months.
The codebase I maintain at W3cert has 40+ modules, and I can still navigate it confidently because every module follows the same patterns.