You're building a multi-user app. You add an admin who can delete products and a sales rep who can't. Your code looks like this:
// Bad approach - checking roles directly
if (user.role === 'admin') {
await deleteProduct(id);
} else {
throw new ForbiddenException('Only admins can delete');
}
This works... until your client asks: "Can I add a Manager role? They should delete products but not manage users."
Now you're stuck. Your code checks roles, not permissions. Time to refactor everything.
I learned this the hard way building Mytreda, and here's what I wish I knew from day one.
Why Role Checks Break Down
Problem 1: Role Explosion
You start with 2 roles: Admin, Sales Rep. Clean and simple.
Then the client wants: Manager, Accountant, Warehouse Staff. Each role needs slightly different permissions.
Your code becomes:
if (role === 'admin' || role === 'manager' || role === 'accountant') {
// delete product
}
Now imagine 10 roles. 50 endpoints. Good luck.
Problem 2: Hard to Change
Business logic is scattered across controllers. Want to let Sales Reps update products?
You'll search the entire codebase for user.role === 'sales_rep' checks. Every change risks breaking something else.
Problem 3: No Granular Control
"Can sales reps view reports but not delete sales?"
Roles are too broad. Permissions are specific. That's the difference.
The Solution: Permission-Based Access Control
Stop asking "What's your role?" Start asking "Do you have permission to do this?"
Step 1: Define Permissions (Not Roles)
// constants/permissions.ts
export enum Permission {
// Products
VIEW_PRODUCTS = 'VIEW_PRODUCTS',
CREATE_PRODUCT = 'CREATE_PRODUCT',
UPDATE_PRODUCT = 'UPDATE_PRODUCT',
DELETE_PRODUCT = 'DELETE_PRODUCT',
// Sales
VIEW_SALES = 'VIEW_SALES',
CREATE_SALE = 'CREATE_SALE',
DELETE_SALE = 'DELETE_SALE',
// Admin
INVITE_USERS = 'INVITE_USERS',
VIEW_ACTIVITY_LOGS = 'VIEW_ACTIVITY_LOGS',
}
Key insight: These are actions, not identities. Anyone can have any combination of these.
Step 2: Map Roles to Permissions
export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
admin: [
// Admins get everything
Permission.VIEW_PRODUCTS,
Permission.CREATE_PRODUCT,
Permission.UPDATE_PRODUCT,
Permission.DELETE_PRODUCT,
Permission.VIEW_SALES,
Permission.CREATE_SALE,
Permission.DELETE_SALE,
Permission.INVITE_USERS,
Permission.VIEW_ACTIVITY_LOGS,
],
sales_rep: [
// Sales reps are limited
Permission.VIEW_PRODUCTS,
Permission.CREATE_PRODUCT,
Permission.VIEW_SALES,
Permission.CREATE_SALE,
],
};
// Helper function
export function hasPermission(role: UserRole, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
Now roles are just bundles of permissions. Want to add a Manager? Just create a new bundle. No code changes.
Step 3: Check Permissions, Not Roles
// Before (role check)
if (user.role === 'admin') {
await deleteProduct(id);
}
// After (permission check)
if (hasPermission(user.role, Permission.DELETE_PRODUCT)) {
await deleteProduct(id);
}
Implementing in NestJS
You can wrap this in a guard + decorator for clean controller code:
// common/guards/permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.get<Permission[]>(
'permissions',
context.getHandler(),
);
if (!requiredPermissions) return true;
const { user } = context.switchToHttp().getRequest();
return requiredPermissions.every(permission =>
hasPermission(user.role, permission)
);
}
}
Now your controllers look like this:
@Controller('products')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ProductsController {
@Post()
@RequirePermission(Permission.CREATE_PRODUCT)
create(@Body() dto: CreateProductDto) {
return this.productsService.create(dto);
}
@Delete(':id')
@RequirePermission(Permission.DELETE_PRODUCT) // Only admins have this
remove(@Param('id') id: string) {
return this.productsService.remove(id);
}
}
Self-documenting code. You know exactly what each endpoint requires.
Why This is Better
1. Add New Roles in One Place
Client: "Add a Manager role."
You: Update one file.
manager: [
Permission.VIEW_PRODUCTS,
Permission.UPDATE_PRODUCT,
Permission.DELETE_PRODUCT,
Permission.VIEW_SALES,
],
Done. No controller changes. No grep'ing the codebase.
2. Change Permissions Without Touching Controllers
Client: "Sales reps should update products now."
You: Add Permission.UPDATE_PRODUCT to the sales_rep array.
No code refactor. No testing 50 endpoints. Just update the map.
3. Easy Testing
it('should allow admin to delete product', () => {
const canDelete = hasPermission('admin', Permission.DELETE_PRODUCT);
expect(canDelete).toBe(true);
});
it('should deny sales_rep from deleting product', () => {
const canDelete = hasPermission('sales_rep', Permission.DELETE_PRODUCT);
expect(canDelete).toBe(false);
});
Your authorization logic is now testable in isolation. Beautiful.
When to Use Each Approach
Use Role Checks When:
- You have 1-2 fixed roles that will NEVER change
- Very simple app (todo list, personal blog)
- Prototype/MVP (but plan to refactor)
Use Permission Checks When:
- Business requirements might change (they always do)
- More than 2 roles
- Enterprise/production apps
- Client might ask: "Can we add a new role?"
If you're not sure, use permissions. It's easier to start with permissions than to refactor from roles later.
TL;DR
❌ Don't do this:
if (user.role === 'admin') { /* do thing */ }
✅ Do this:
if (hasPermission(user.role, Permission.DO_THING)) { /* do thing */ }
Why:
- Define permissions (VIEW, CREATE, UPDATE, DELETE)
- Map roles to permissions in ONE place
- Check permissions, not roles
- New role? Just update the permissions map
Your turn: Look at your current codebase. How many places do you check user.role === 'something'? That's how many places will break when you add a new role.
Refactor now, thank yourself later.
Questions? Drop a comment below.
Want to connect?
- Portfolio: tochukwu-nwosa.vercel.app
- Twitter/X: @tochukwudev
- GitHub: tochukwunwosa
- LinkedIn: nwosa-tochukwu
Top comments (0)