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);
}
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
roleto 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);
}
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
}
// 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;
}
// ChangeEmailDto.ts - Requires verification
import { IsEmail, IsString } from 'class-validator';
export class ChangeEmailDto {
@IsEmail()
newEmail: string;
@IsString()
verificationCode: string; // Sent to old email
}
// 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
}
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,
});
}
}
// 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);
}
}
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';
}
}
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() {}
Real-World Benefits
After restructuring my endpoints:
-
Security improved drastically
- Users can't accidentally change their role
- Password changes require verification
- Admin operations are clearly separated
-
Code is way clearer
- I know exactly what each endpoint does
- New developers can understand it instantly
- No "god method" doing 10 different things
-
Testing became simpler
- Test profile updates separately from password changes
- Mock only what each endpoint needs
- No complex conditional testing
-
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:
- Portfolio: tochukwu-nwosa.vercel.app
- GitHub: @tochukwunwosa
- Twitter: @tochukwudev
- LinkedIn: nwosa-tochukwu
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)