Skip to content
ExponaBLOG
NESTJS · DAY 094 / 100

My NestJS code review checklist: lessons from production

What I look for in every NestJS pull request.

Expona Admin8 min read

After 94 days of building production NestJS applications, I've learned that a good code review separates systems that barely work from platforms that actually scale. My NestJS code review checklist has become the backbone of every pull request I evaluate—it catches the subtle issues that don't show up until 3 AM when the database is locked or a user gains access to someone else's data. Today I'm sharing exactly what I look for.

Validation — Every input has a DTO with validation. No any.

The first thing I check is whether every endpoint has a DTO with proper validation decorators. I've seen too many endpoints that accept any or skip validation entirely, and the fallout is always the same: malformed data gets deeper into the system than it should.

Here's what I expect to see:

typescript
import { IsEmail, IsString, IsNotEmpty, MinLength, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @IsNotEmpty()
  @MinLength(8)
  password: string;

  @IsString()
  @IsOptional()
  firstName?: string;

  @IsString()
  @IsOptional()
  lastName?: string;
}

Then in the controller, I verify the DTO is actually used:

typescript
@Post('users')
@HttpCode(HttpStatus.CREATED)
async createUser(@Body() createUserDto: CreateUserDto) {
  return this.userService.create(createUserDto);
}

The key here is that NestJS's built-in validation pipe automatically validates the shape and constraints before the handler runs. No sneaking past with any, no hand-rolling regex checks. One thing I watch for: custom validators. If a field needs domain-specific validation (like checking if an email is already registered), I want that in a custom validator decorator, not scattered in the service.

typescript
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  constructor(private readonly userService: UserService) {}

  async validate(email: string): Promise {
    const existing = await this.userService.findByEmail(email);
    return !existing;
  }

  defaultMessage(): string {
    return 'Email is already registered';
  }
}

Authorization — Guard plus permission check. Ownership verified for resources.

Authentication isn't the same as authorization, and I check both meticulously. A JWT guard proves you are who you say you are, but does it verify you can actually do what you're asking to do?

I look for guards on protected routes:

typescript
@UseGuards(JwtAuthGuard)
@Get('posts/:id')
async getPost(@Param('id') postId: string, @Request() req) {
  return this.postService.getPost(postId);
}

But the real question is whether ownership is verified. If I'm fetching a post, I need to confirm the post actually belongs to me or is public. This is where it gets tricky:

typescript
@UseGuards(JwtAuthGuard)
@Patch('posts/:id')
async updatePost(
  @Param('id') postId: string,
  @Body() updatePostDto: UpdatePostDto,
  @Request() req
) {
  const post = await this.postService.findById(postId);
  
  if (post.userId !== req.user.id) {
    throw new ForbiddenException('You cannot modify this post');
  }

  return this.postService.update(postId, updatePostDto);
}

I've seen pull requests where this check was missing entirely, or worse, where it was only done on specific operations. For resources with ownership semantics, every read/write that isn't explicitly public needs that verification. I also prefer moving this into a custom guard or using a resolver that handles it automatically, so it's not left to chance:

typescript
@Injectable()
export class PostOwnershipGuard implements CanActivate {
  constructor(
    private readonly postService: PostService,
    private readonly reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise {
    const request = context.switchToHttp().getRequest();
    const postId = request.params.id;
    const userId = request.user.id;

    const post = await this.postService.findById(postId);
    if (!post) throw new NotFoundException();

    return post.userId === userId;
  }
}

Data access — No N+1, selects limited, transactions where needed.

Performance problems hide in plain sight during code review. I always check the query patterns, especially in services where data is loaded.

N+1 is the classic culprit. Here's what I don't want to see:

typescript
async getUsersWithPosts(userId: string) {
  const users = await this.usersRepository.find({ where: { id: userId } });
  // This loops and fires a query per user
  for (const user of users) {
    user.posts = await this.postsRepository.find({ where: { userId: user.id } });
  }
  return users;
}

Instead, eager load with relations:

typescript
async getUsersWithPosts(userId: string) {
  return this.usersRepository.find({
    where: { id: userId },
    relations: ['posts'],
  });
}

I also check column selection. Fetching entire entities when you only need an ID or a name wastes memory and bandwidth:

typescript
async getUserId(email: string): Promise {
  const user = await this.usersRepository.findOne({
    where: { email },
    select: ['id'], // Only fetch ID
  });
  return user?.id;
}

Transactions matter when multiple operations must succeed or fail together. If a payment service charges a card and then updates an order status in two separate queries, a crash between them leaves data corrupt:

typescript
async processOrder(orderId: string) {
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    await queryRunner.manager.update(Order, { id: orderId }, { status: 'processing' });
    await this.paymentService.charge(orderId);
    await queryRunner.commitTransaction();
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release();
  }
}

Errors — Domain errors mapped, no stack traces leaked.

How you handle errors tells me a lot about whether this code is ready for production. Every error response that leaves your API should be intentional and sanitized.

I expect custom domain exceptions, not generic errors:

typescript
export class InsufficientFundsException extends BadRequestException {
  constructor(available: number, required: number) {
    super(`Insufficient funds. Available: ${available}, Required: ${required}`);
  }
}

export class UserAlreadyExistsException extends ConflictException {
  constructor(email: string) {
    super(`User with email ${email} already exists`);
  }
}

Then map these cleanly in an exception filter:

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

    if (exception instanceof HttpException) {
      return response.status(exception.getStatus()).json({
        statusCode: exception.getStatus(),
        message: exception.getResponse(),
      });
    }

    // Never expose stack trace to client
    console.error(exception); // Log it server-side
    response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
      statusCode: 500,
      message: 'Internal server error',
    });
  }
}

The critical rule: stack traces stay in logs, not responses. A leaked stack trace is a free attack surface for someone trying to exploit your system.

Tests — Business rules unit-tested; critical flows covered end to end.

I don't expect 100% code coverage, but I do expect business logic to be tested. A utility function that formats dates doesn't need tests. A calculation that determines if a user can withdraw funds absolutely does.

Unit tests for services:

typescript
describe('AccountService', () => {
  let service: AccountService;
  let repository: Repository;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AccountService,
        {
          provide: getRepositoryToken(Account),
          useValue: {
            findOne: jest.fn(),
            save: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get(AccountService);
    repository = module.get>(getRepositoryToken(Account));
  });

  it('should throw when withdrawing more than available balance', async () => {
    const account = { balance: 100 };
    (repository.findOne as jest.Mock).mockResolvedValue(account);

    await expect(
      service.withdraw('acc-1', 150),
    ).rejects.toThrow(InsufficientFundsException);
  });

  it('should deduct amount and save when balance is sufficient', async () => {
    const account = { id: 'acc-1', balance: 500 };
    (repository.findOne as jest.Mock).mockResolvedValue(account);
    (repository.save as jest.Mock).mockResolvedValue({ ...account, balance: 450 });

    const result = await service.withdraw('acc-1', 50);

    expect(result.balance).toBe(450);
    expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({ balance: 450 }));
  });
});

End-to-end tests for critical flows (like payments, authentication, or data export):

typescript
describe('Payment Flow (e2e)', () => {
  it('should create order, charge card, and update status atomically', async () => {
    const response = await request(app.getHttpServer())
      .post('/orders')
      .set('Authorization', `Bearer ${validToken}`)
      .send({ amount: 5000, cardId: 'card-123' })
      .expect(HttpStatus.CREATED);

    const orderId = response.body.id;

    const order = await orderRepository.findOne({ where: { id: orderId } });
    expect(order.status).toBe('completed');
    expect(order.paidAmount).toBe(5000);
  });
});

Common mistakes

  • Skipping DTO validation on "simple" endpoints. Every input is an opportunity for bad data. No exceptions.
  • Mixing authentication and authorization. A JWT guard doesn't prove permission. Verify ownership and roles explicitly.
  • Loading entire entities when you only need a field. Use select to be specific. It compounds at scale.
  • Catching all exceptions with a generic handler. You lose information about what actually failed. Be specific.
  • Skipping tests for "obvious" logic. The business rules are where bugs live. Test them.
  • Leaking stack traces in error responses. It's an attack surface. Log server-side, respond with a message.

Key takeaways

  • Validation is your first line of defense. Use DTOs with decorators, never any.
  • Authorization is separate from authentication. Verify ownership and permissions on every protected operation.
  • Query patterns matter. Watch for N+1, use eager loading, and select only what you need.
  • Handle errors intentionally. Map domain exceptions and never expose stack traces to clients.
  • Test business rules and critical flows. Unit tests for logic, e2e tests for journeys.
  • Code review is about catching what tests miss—ownership checks, transaction boundaries, and error handling edge cases.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles