From Monolith to Modular: How I Built a NestJS-Like RBAC Boilerplate Without Knowing It
I started with a basic Express template as a junior developer. Over time, I refactored it into a modular RBAC system with TypeORM, dependency injection, and custom annotations. A friend pointed out it mimicked NestJS. This post details the journey and shares the boilerplate for your use.
I built this RBAC boilerplate step by step. It handles roles, permissions, and guards in a modular way. The code uses Express and TypeScript. TypeORM manages the database. I added dependency injection with tsyringe. Custom annotations make it clean.
You can find the full code on GitHub.This project started years ago. I worked as a junior backend engineer at Codepoint. Abdulfeta Jemal shared a clean Express template that made development easier. Raw Node.js was very overwhelming for me at the time so this made it very easy to understand. It had controllers, services, and routes in one folder. I used it for every API.
The structure felt flat. Entities sat next to controllers. I imported files with long relative paths. It worked for small apps. As projects grew, it was very time consuming to search for files, so I needed to change that.
The First Change: Folder Structure
I reorganized first. I grouped files by feature. For auth, I made folders for controller, service, dto, routes, and entity. Shared items like utils stayed at the root.
src/
├── auth/
│ ├── controller/
│ │ └── auth.controller.ts
│ ├── entity/
│ │ └── user.entity.ts
│ ├── service/
│ │ └── auth.service.ts
│ └── routes/
│ └── auth.routes.ts
├── utils/
│ └── logger.util.ts
└── index.tsThis setup cut import paths. Auth code stayed together. I updated index.ts to load routes from each module. The app felt more like a collection of features.
Adding Dependency Injection
Next, I added DI. I chose tsyringe. It works with reflect-metadata. I marked services and controllers with @injectable(). I injected repos and other services in constructors.
In index.ts, I registered everything in a container. Controllers resolved from the container during route setup. This broke tight coupling. Tests became easier.
@injectable()
export class AuthService {
constructor(
@inject('UserRepository') private userRepo: any
) {}
}I built a central container.ts to orchestrate registrations. Each module exported an init function. I called them in order to avoid circular dependencies.
Custom Annotations and Spring Boot Influence
I learned Spring Boot at work. Its annotations were really cool they cut out a lot of code. I created @Repository for auto-injecting TypeORM repos. I used reflect-metadata to store entity info.
I added @Module to flag module files. A scanner in container.ts loaded entities, services, and controllers. This made new modules plug-and-play.
export function Repository(entity: any): ParameterDecorator {
return (target, propertyKey, parameterIndex) => {
const token = ${target.constructor.name.toLowerCase()}:${entity.name}Repository;
Reflect.defineMetadata('repo:entity', { entity, token }, target, 'constructor', parameterIndex);
};
}Guards went dynamic. I scanned module/guards folders at seed time. Each file exported a const array of strings. I upserted them as permissions in the DB.
The NestJS "Revelation" 😂
My friends were reviewing the code. They said it looked like NestJS. I had no idea NestJS existed. I researched about NestJS. It uses modules, decorators, and DI out of the box.
I saw the parallels. My @Module mimicked Nest's modules. Custom annotations echoed its providers. I switched to NestJS for new projects. It was cleaner and better.
- NestJS has built-in guards for RBAC.
- Its CLI generates modules fast.
- Validation pipes replace Joi schemas.
- Swagger integration is easy.
- The community is large and active.
NestJS wins for production. Use it for teams or complex apps. This vanilla setup shows the ideas behind it. It builds the same patterns without a framework.
How the Boilerplate Works
The app starts in index.ts. It loads environment variables with dotenv. TypeORM connects to MySQL and initializes the data source. Entities load dynamically from each module's entity folder. This happens with async imports to keep things modular.
Container orchestration runs next. It scans files marked with @Module. For each module, it registers repositories with tokens like 'auth:UserRepository'. Services and controllers get added to the tsyringe container if they have @AutoRegister.
Routes pull from each module's routes folder. Each route object includes method, path, controller, guard string, middleware, and action. The guard triggers the authentication middleware to check JWT claims against user permissions.
export const authRoutes = [
{
method: 'post',
route: '/auth/login',
controller: AuthController,
guard: 'GUEST',
middleware: [SchemaValidator(loginBodySchema)],
action: 'login',
},
];Seeding happens in dev mode only. It scans guard files from module/guards folders. Each file exports a const array of permission strings. The code upserts these as Permission records in the database. It then assigns all to an 'admin' role and creates an admin user with that role.
Folder Structure
The structure groups code by feature. Each module has its own subfolders for entity, guards, service, controller, routes, and dto. Shared items like middlewares and utils sit at the root. This keeps concerns separate and imports short.
src/
├── auth/
│ ├── controller/
│ │ └── auth.controller.ts
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── login-user.dto.ts
│ ├── entity/
│ │ └── user.entity.ts
│ ├── guards/
│ │ └── auth.guards.ts
│ ├── module.ts
│ ├── routes/
│ │ └── auth.routes.ts
│ └── service/
│ └── auth.service.ts
├── permission/
│ ├── controller/
│ │ └── permission.controller.ts
│ ├── dto/
│ │ └── permission.dto.ts
│ ├── entity/
│ │ └── permission.entity.ts
│ ├── guards/
│ │ └── permission.guards.ts
│ ├── module.ts
│ ├── routes/
│ │ └── permission.routes.ts
│ └── service/
│ └── permission.service.ts
├── role/
│ ├── controller/
│ │ └── role.controller.ts
│ ├── dto/
│ │ ├── create-role.dto.ts
│ │ └── update-role.dto.ts
│ ├── entity/
│ │ └── role.entity.ts
│ ├── guards/
│ │ └── role.guards.ts
│ ├── module.ts
│ ├── routes/
│ │ └── role.routes.ts
│ └── service/
│ └── role.service.ts
├── container.ts
├── data-source.ts
├── enums/
│ └── UserType.ts
├── index.ts
├── middlewares/
│ └── auth/
│ └── authentication.middleware.ts
├── schema/
│ ├── auth-schema.ts
│ ├── permission-schema.ts
│ └── role-schema.ts
├── seed.ts
└── utils/
├── annotations.ts
├── logger.util.ts
└── response.util.tsEntities use TypeORM decorators with explicit column types for MySQL. Relations like ManyToMany handle joins with @JoinTable. Guards files export const arrays for permissions. DTOs define request shapes. Schemas use Joi for validation in middleware.
The authentication middleware verifies JWTs and attaches user ID to requests. It flattens role permissions into a Set for quick checks. Guards like 'GUEST' skip auth entirely.
In services, @Repository injects repos. For example, AuthService uses UserRepository for login and hashing. RoleService joins permissions for assignments. Pagination uses query builder with Like for search.
Controllers resolve from the container. They call service methods and return ApiResponse objects. Routes loop in index.ts to attach middleware and handlers dynamically.
Key Files and Patterns
- data-source.ts: Dynamic entity loading with await import.
- container.ts: Async scans for services and controllers.
- seed.ts: Guard collection and admin setup.
- middlewares/auth: JWT verify and permission check.
- utils/annotations.ts: @Module, @Repository for metadata.
Relations use TypeORM's ManyToMany with JoinTable. Soft deletes via is_void flag.
Setup and Usage
Clone the repo. Install deps with npm i. Copy .env.example to .env and fill DB details. Run npm run dev.
git clone https://github.com/penealfa/rbac-boilerplate.git
cd rbac-boilerplate
npm install
cp .env.example .env
npm run devLogin with admin@example.com / admin. Use the token for protected routes like /roles.
Add a module: Copy auth/ to user/. Export routes in index.ts. Add to modules array in container.ts and seed.ts.
Lessons from this Journey
Start simple. Refactor as needs grow. Annotations cut code. DI tests well. Frameworks like NestJS save time.
This boilerplate works for small teams or learning. It runs on Node 20+ with MySQL.
Final Thoughts
I built NestJS without knowing it. The process taught me modular design. Try this code. It fits prototypes or proofs of concept. For scale, pick NestJS.