Skip to main content
NestJS Architecture: Patterns That Scale

NestJS Architecture: Patterns That Scale

Sep 21, 2025

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.

© 2026 Tawan. All rights reserved.