admond@portfolio:~/blog
← all posts
$ cat structuring-nestjs-apis-for-production.md

Structuring NestJS APIs for Production

Mon Dec 08 · 7 min read
[nestjs][node.js][architecture]

Why structure matters more than speed

When you start a NestJS project, the framework gives you a clean src/ directory and a generous AppModule. For the first two weeks, everything feels fast. Modules are small, dependencies are obvious, and tests are trivial to write. Then the codebase grows. A second developer joins. The third feature request requires touching six files across four modules. At this point, the structure you chose in week one is either your biggest asset or your biggest liability.

This post covers the structural patterns I use on production NestJS services — ones that have held up across teams of 2 to 12 developers and codebases from 10k to 200k lines.

Domain-first module organization

Avoid organizing by technical role (controllers/, services/, repositories/). Instead, organize by domain:

src/
  auth/
    auth.module.ts
    auth.controller.ts
    auth.service.ts
    auth.guard.ts
    dto/
      login.dto.ts
      register.dto.ts
  users/
    users.module.ts
    users.controller.ts
    users.service.ts
    users.repository.ts
    entities/
      user.entity.ts
  shared/
    shared.module.ts
    pipes/
    decorators/
    filters/

The rule is simple: everything related to a feature lives in one directory. A new developer should be able to find all order-related code without hunting.

Keep modules small and focused

Each module should have one clear responsibility. If you find yourself debating whether to put something in UsersModule or AuthModule, that's a sign the feature might deserve its own module.

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService],
})
export class UsersModule {}

Notice exports. Only export what other modules genuinely need. Exporting everything defeats encapsulation and makes refactoring painful later.

Repository pattern for data access

NestJS doesn't enforce a data-access layer, but you should. Putting TypeORM queries directly in services makes them hard to test and impossible to swap out. Wrap all data access in a dedicated repository class:

@Injectable()
export class UsersRepository {
  constructor(
    @InjectRepository(User)
    private readonly repo: Repository<User>,
  ) {}

  async findByEmail(email: string): Promise<User | null> {
    return this.repo.findOne({ where: { email } });
  }

  async create(data: Partial<User>): Promise<User> {
    const user = this.repo.create(data);
    return this.repo.save(user);
  }
}

Now your service depends on UsersRepository, not on TypeORM directly. Mocking it in tests is trivial.

Global exception filter

Define one exception filter that handles everything. Don't let NestJS's default error shapes leak into your API contract.

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
    });
  }
}

Register it globally in main.ts:

app.useGlobalFilters(new AllExceptionsFilter());

Validation with class-validator

Always validate incoming DTOs at the boundary. Never trust the client. Enable the global validation pipe:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

With whitelist: true, a client sending extra fields won't silently pollute your data layer.

Configuration management

Never hardcode environment values. Use @nestjs/config with a validated schema:

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        DATABASE_URL: Joi.string().required(),
        JWT_SECRET: Joi.string().min(32).required(),
        PORT: Joi.number().default(3000),
      }),
    }),
  ],
})
export class AppModule {}

If a required env variable is missing at startup, the app crashes immediately with a clear error — before serving a single request. This is the behavior you want in production.

The pattern that ties it together

These aren't prescriptive rules — they're the defaults that require the least explanation when someone new joins the project.

← all posts
admond tamang · portfoliotheme: mono