DEV Community

Tochukwu Nwosa
Tochukwu Nwosa

Posted on

NestJS Week 3: Why Your User Update Endpoint Shouldn't Do Everything

Catching Up

If you've been following my NestJS journey, you know I've covered:

This week brought another "aha moment" that completely changed how I think about API design.

The Mistake I Almost Made

While building user management for my project, I was about to create this:

// ❌ One endpoint to rule them all
@Patch('users/:id')
updateUser(@Param('id') id: string, @Body() updateDto: UpdateUserDto) {
  // Update anything: profile, password, email, role, account status...
  return this.usersService.update(id, updateDto);
}
Enter fullscreen mode Exit fullscreen mode

Seemed efficient, right? One endpoint, one DTO, one service method. Very DRY.

Then I learned about two principles that made me rethink everything.

Two Principles That Changed My Approach

1. Single Responsibility Principle (SRP)

Remember from Week 2 how we used exception filters to separate error handling from business logic? Same concept here.

Each endpoint should have ONE reason to exist.

When one endpoint handles profile updates, password changes, email verification, AND admin operations:

  • Changing password logic forces you to touch profile update code
  • Adding email verification affects password flows
  • Admin operations are mixed with user self-service
  • Testing becomes a nightmare

2. Principle of Least Privilege

Give the minimum permissions necessary. Nothing more.

With one big update endpoint:

  • Users might change their own role to admin
  • Password changes might not require old password verification
  • Email changes could bypass verification flows
  • You can't properly separate user actions from admin actions

The Better Way: Separate Endpoints

// Profile updates (businessName, whatsappNumber)
@Patch(':id')
@UseGuards(AuthGuard)
updateUserProfile(
  @Param('id') id: string,
  @Body() dto: UpdateUserDto
) {
  return this.usersService.updateProfile(id, dto);
}

// Password changes (requires old password verification)
@Post('auth/change-password')
@UseGuards(AuthGuard)
changePassword(@Body() dto: ChangePasswordDto) {
  return this.authService.changePassword(dto);
}

// Email changes (requires email verification)
@Post('auth/change-email')
@UseGuards(AuthGuard)
changeEmail(@Body() dto: ChangeEmailDto) {
  return this.authService.changeEmail(dto);
}

// Admin operations (isActive, role changes)
@Patch(':id/status')
@UseGuards(AuthGuard, AdminGuard)
updateUserStatus(
  @Param('id') id: string,
  @Body() dto: UpdateUserStatusDto
) {
  return this.usersService.updateStatus(id, dto);
}
Enter fullscreen mode Exit fullscreen mode

Each endpoint has:
✅ ONE specific purpose
✅ Minimal permissions needed
✅ Clear security boundaries
✅ Its own validation logic

DTOs That Match Operations

Remember from Week 1 how we used DTOs to define data structure? Now we're using them for security too.

// UpdateUserDto.ts - Profile info only
import { IsOptional, IsString } from 'class-validator';

export class UpdateUserDto {
  @IsOptional()
  @IsString()
  businessName?: string;

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

  // Notice: NO password, email, role, or isActive
}
Enter fullscreen mode Exit fullscreen mode
// ChangePasswordDto.ts - Requires verification
import { IsString, MinLength } from 'class-validator';

export class ChangePasswordDto {
  @IsString()
  @MinLength(8)
  oldPassword: string; // Must verify they know current password

  @IsString()
  @MinLength(8)
  newPassword: string;
}
Enter fullscreen mode Exit fullscreen mode
// ChangeEmailDto.ts - Requires verification
import { IsEmail, IsString } from 'class-validator';

export class ChangeEmailDto {
  @IsEmail()
  newEmail: string;

  @IsString()
  verificationCode: string; // Sent to old email
}
Enter fullscreen mode Exit fullscreen mode
// UpdateUserStatusDto.ts - Admin operations only
import { IsOptional, IsBoolean, IsEnum } from 'class-validator';

export class UpdateUserStatusDto {
  @IsOptional()
  @IsBoolean()
  isActive?: boolean;

  @IsOptional()
  @IsEnum(UserRole)
  role?: UserRole;

  // Notice: NO personal user data
}
Enter fullscreen mode Exit fullscreen mode

Each DTO has only the fields relevant to its operation. This enforces the principle of least privilege at the validation layer.

Service Layer Implementation

In your service, keep the logic separate too:

// users.service.ts
@Injectable()
export class UsersService {

  // Profile updates - anyone can update their own profile
  async updateProfile(id: string, dto: UpdateUserDto) {
    const user = await this.findOne(id);

    if (!user) {
      throw new NotFoundException('User not found');
    }

    // Only update allowed fields
    return this.userRepository.update(id, {
      businessName: dto.businessName,
      whatsappNumber: dto.whatsappNumber,
    });
  }

  // Status updates - admin only
  async updateStatus(id: string, dto: UpdateUserStatusDto) {
    const user = await this.findOne(id);

    if (!user) {
      throw new NotFoundException('User not found');
    }

    return this.userRepository.update(id, {
      isActive: dto.isActive,
      role: dto.role,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
// auth.service.ts
@Injectable()
export class AuthService {

  async changePassword(dto: ChangePasswordDto) {
    // Verify old password
    const isValid = await this.verifyPassword(dto.oldPassword);

    if (!isValid) {
      throw new UnauthorizedException('Current password is incorrect');
    }

    // Hash and update new password
    const hashedPassword = await bcrypt.hash(dto.newPassword, 10);
    return this.updatePassword(hashedPassword);
  }

  async changeEmail(dto: ChangeEmailDto) {
    // Verify the code sent to old email
    const isValid = await this.verifyEmailCode(dto.verificationCode);

    if (!isValid) {
      throw new UnauthorizedException('Invalid verification code');
    }

    // Update email
    return this.updateEmail(dto.newEmail);
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how:

  • Profile updates are in UsersService
  • Auth operations are in AuthService
  • Each method has ONE responsibility
  • Verification logic is built into each operation

Guards for Access Control

Just like we used exception filters in Week 2, guards enforce security at the endpoint level:

// Basic auth guard - must be logged in
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return !!request.user; // Has user session
  }
}

// Admin guard - must be admin
@Injectable()
export class AdminGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.user?.role === 'admin';
  }
}
Enter fullscreen mode Exit fullscreen mode

Then apply them:

// Anyone logged in can update their profile
@Patch(':id')
@UseGuards(AuthGuard)
updateProfile() {}

// Only admins can change user status
@Patch(':id/status')
@UseGuards(AuthGuard, AdminGuard)
updateStatus() {}
Enter fullscreen mode Exit fullscreen mode

Real-World Benefits

After restructuring my endpoints:

  1. Security improved drastically

    • Users can't accidentally change their role
    • Password changes require verification
    • Admin operations are clearly separated
  2. Code is way clearer

    • I know exactly what each endpoint does
    • New developers can understand it instantly
    • No "god method" doing 10 different things
  3. Testing became simpler

    • Test profile updates separately from password changes
    • Mock only what each endpoint needs
    • No complex conditional testing
  4. Maintenance is easier

    • Changing password logic doesn't touch profile updates
    • Adding email verification doesn't affect admin operations
    • Each feature can evolve independently

Common Questions

Q: Isn't this more code?
A: Yes, but it's better code. Would you rather have one confusing file or three clear ones?

Q: What about code duplication?
A: You're not duplicating logic—you're separating concerns. Big difference.

Q: When should I use one endpoint?
A: When operations are truly the same thing. Example: updating different fields in a blog post title/content. But user profile vs. password? Different operations.

Connecting the Dots

This week's lessons build on everything from previous weeks:

  • Week 1: DTOs define structure → Now they also define security boundaries
  • Week 2: Exception filters centralize errors → Separate endpoints reduce error complexity
  • Week 3: SRP and Least Privilege → Make your API secure by design

Each principle reinforces the others. That's what makes NestJS powerful—it encourages good architecture from the start.

Key Takeaways

One endpoint = one responsibility - Don't mix operations
Least privilege = better security - Minimum permissions needed
Separate DTOs for separate concerns - Validation IS security
Guards enforce boundaries - Use them liberally
Service methods should be focused - Each does ONE thing well

What's Next?

Next week, I'll be diving into:

  • Database integration with Mongoose
  • Relationships between entities
  • Migration strategies

What I'm Building

I'm documenting my journey from frontend to full-stack while building Tech Linkup, a tech events platform for Nigerian cities. Every mistake, every lesson, every breakthrough—shared publicly.

Let's connect:

Have you made similar mistakes in API design? Or do you have a different approach? Let's discuss in the comments! 👇


P.S. - If you're also learning NestJS, check out my previous posts in this series. I'm documenting everything—the good, the bad, and the "why didn't anyone tell me this earlier?" moments.

Top comments (0)