Modular Monolith Boilerplate
Node.js + TypeScript. A monolith today, a set of independently deployable microservices tomorrow — same codebase, controlled by one environment variable.
About
This project is a batteries-included starting point for backend services: decorator-based routing, dependency injection, and a shared CRUD contract over two datastore drivers, wired together by convention rather than manual registration.
The goal is to let a team start as a single deployable monolith, then peel off any module (Auth, User, Post, …) into its own service later by changing the MODULES environment variable — no rewrite required, because controllers, services, and repositories are already isolated per module.
Why This Framework
Monolith-to-microservices path
Deploy as one service today; split any module into its own deployable service later via the MODULES environment variable, without rewriting code.
Convention-based wiring
Controllers, services, and repositories are discovered by file naming and folder location — no manual route registration or DI configuration.
Two datastores, one contract
PostgreSQL (Drizzle) and MongoDB (Mongoose) repositories both implement the same BaseInterface, so a module picks whichever store fits without changing its service layer.
Security defaults included
Helmet headers, a CORS allow-list, tiered rate limiting, JWT auth, and request validation are configured at the framework level, not left to each module to add.
Full TypeScript
Typed entities, repositories, and request DTOs throughout, checked at compile time rather than discovered at runtime.
Code generation
pnpm make module <Name> scaffolds a complete module — controller, service, repository, interface, and schema — matching the existing structure.
Where This Fits
Teams starting as a single service
Get authentication, user management, rate limiting, and structured logging running without first deciding on a microservices topology.
Organizations standardizing module structure
Every module follows the same controller → service → repository shape, so onboarding to module five looks the same as module one.
Systems needing relational and document data
Entities requiring strong relational constraints (users, auth) alongside entities needing flexible schemas (posts, activity data), in the same application.
Incremental service extraction
Move a single module to its own deployment (e.g. MODULES=auth) once it needs independent scaling, without restructuring the rest of the codebase.
Design & Request Flow
Every request flows through the same layered pipeline, regardless of which module handles it. Controllers never talk to the database directly — they call a Service, which depends on a Repository interface, resolved at runtime by dependency injection.
A request passes through middleware to a Controller, which may read or write Redis before delegating to a Service and Repository. The Repository resolves to whichever store — Postgres or MongoDB — that module is configured for.
ControllerLoader
Recursively scans App/Modules/<Module>/Controllers/*Controller.ts, resolves the exported class from each file, and builds a manifest module cached under .cache/controller/. Cache is invalidated by comparing file mtimes, so new controllers are picked up automatically. The same loader (base path Utils/GlobalMiddlewares/, suffix Middleware) auto-loads global middlewares.
ModuleServiceProvider
Scans App/Modules/<Module>/Repositories/*Repository.ts and registers each class in the TypeDI container as a lazy factory, keyed by token <Name>Interface (e.g. UserRepository → "UserInterface"). Services depend on the string token, not the concrete class.
Dual persistence, one contract
BaseInterface<T> defines the CRUD contract every repository implements. DrizzleBaseRepository (Postgres) and MongooseBaseRepository (MongoDB) each implement it once; module repositories extend the matching base and supply only the table/model and mapping functions.
One codebase, many services
The MODULES env var (comma-separated) restricts which modules' controllers and repositories load at boot. Leave it empty to run the full monolith, or set MODULES=auth to deploy Auth as its own microservice against the same Postgres instance.
Project Structure
Every module — User, Auth, Post, Comment — follows the same folder shape. Once you understand one module, you understand all of them.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
App/
├── Config/ # app.ts, database.ts, security.ts — env-driven configuration
├── Bootstrap/ # Drizzle.ts, Mongoose.ts, Redis.ts, DatabaseConnection.ts, seed.ts
├── Utils/
│ ├── Core/ # ClassAutoLoader, ModuleServiceProvider, BaseController
│ │ └── database/ # BaseInterface, DrizzleBaseRepository, MongooseBaseRepository
│ ├── Common/ # Logger, ApiResponse, ExecutionTimeMiddleware
│ └── GlobalMiddlewares/ # Auto-loaded: RateLimitMiddleware, ClassValidationMiddleware
├── Modules/
│ ├── User/ # Controllers/ Services/ Schemas/ Repositories/ (Postgres)
│ ├── Auth/ # Login, JWT sessions, OAuth (Postgres)
│ ├── Post/ # Controllers/ Services/ Repositories/ (MongoDB)
│ ├── Comment/ # Controllers/ Services/ Repositories/ (MongoDB)
│ └── Site/ # This page — Views/ (templates) + Public/ (assets)
└── main.ts # Bootstrap: bind repositories, load controllers, connect DB + Redis, listen
New modules are generated, not hand-wired: pnpm make module Product (Postgres) or pnpm make module Comment --db=mongodb scaffolds a Controller, Service, Repository, Interface, and Schema that all follow this same layout.
Code Preview
A read-only look at how the User module implements the layered pattern above — real source, trimmed for width. Switch files with the tabs; nothing here is editable.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
@Service()
@JsonController('/users')
@UseBefore(AuthMiddleware)
export class UserController extends BaseController {
constructor(
@Inject() private readonly userService: UserService,
@Inject() private readonly cacheService: CacheService
) { super(); }
@Get('/')
async list(@QueryParam('page') page = 1, @QueryParam('pageSize') pageSize = 20) {
const cacheKey = this.cacheService.listKey('users', page, pageSize);
const cached = await this.cacheService.get(cacheKey);
if (cached) return this.response.paginated(cached.users, cached.total, page, pageSize);
const { users, total } = await this.userService.list(page, pageSize);
await this.cacheService.set(cacheKey, { users, total });
return this.response.paginated(users, total, page, pageSize);
}
@Post('/')
async create(@Body() body: CreateUserRequest, @Res() res: Response) {
const existing = await this.userService.getByEmail(body.email);
if (existing) return res.status(HttpStatus.CONFLICT).json(this.response.error('Email already exists'));
const user = await this.userService.create(body);
return res.status(HttpStatus.CREATED).json(this.response.success(user));
}
// ...update() and delete() follow the same controller -> service -> response shape
}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
@Service()
export class UserService {
constructor(
@Inject('UserInterface') private readonly userRepository: UserInterface,
@Inject() private readonly mailService: MailService
) {}
async create(data: Parameters<UserInterface['create']>[0]): Promise<User> {
const hashed = await bcrypt.hash(data.password, 10);
const user = await this.userRepository.create({ ...data, password: hashed });
this.mailService
.sendWelcomeEmail({ email: user.email, name: user.name })
.catch((err) => logger.warn('Welcome email failed', { email: user.email, err: err.message }));
return entityToUser(user);
}
async getByEmail(email: string): Promise<User | null> {
const user = await this.userRepository.findOneBy('email', email);
return user ? entityToUser(user) : null;
}
// ...update(), delete(), list() call the same repository, hashing only on password changes
}
1 2 3 4 5 6 7 8
@Service()
export class UserRepository
extends DrizzleBaseRepository<UserEntity, UserCreateInput, UserUpdateInput>
implements UserInterface
{
protected readonly table = user;
// find, create, update, delete, findAll, findOneBy, listSorted, … — inherited
}
1 2 3 4 5 6 7 8 9 10 11
export const user = pgTable('User', {
id: uuid('id').primaryKey().defaultRandom(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }),
createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { mode: 'date' }).defaultNow().notNull(),
});
export type UserRow = typeof user.$inferSelect;
export type UserInsert = typeof user.$inferInsert;
Modules
| Module | Store | Purpose | Base repository |
|---|---|---|---|
| User | PostgreSQL (Drizzle) | User records, CRUD | DrizzleBaseRepository |
| Auth | PostgreSQL (Drizzle) | Login/logout, JWT sessions, Google/Facebook/GitHub OAuth | DrizzleBaseRepository |
| Post | MongoDB (Mongoose) | Post records, CRUD | MongooseBaseRepository |
| Comment | MongoDB (Mongoose) | Comment records, CRUD | MongooseBaseRepository |
| Site | none | This landing page | — |
New modules are generated, not hand-wired: pnpm make module Product (Postgres) or pnpm make module Comment --db=mongodb scaffolds Controller/Service/Repository/Interface/Schema following this same structure.
Security
Helmet
Standard security headers; HSTS enabled in production; CSP togglable via HELMET_CSP_ENABLED.
CORS
Origins restricted via ALLOWED_ORIGIN (comma-separated, or *).
Rate limiting
Global limit via RATE_LIMIT_MAX/_WINDOW_MS; a stricter limiter (RATE_LIMIT_AUTH_MAX) applies only to /auth/login and OAuth callbacks.
JWT
Signed session tokens; startup warns in production if JWT_SECRET is under 32 characters.
Validation
Request DTOs validated with class-validator via routing-controllers' validation: true.
Body limit
BODY_PARSER_LIMIT (default 1mb) guards against oversized-payload abuse.
Getting Started
Three common ways to run this locally — switch tabs to see each.
1 2 3 4 5 6 7
cp .env.example .env
# edit DATABASE_URL / MONGODB_URI / REDIS_URL
npm install
npm run db:push # sync Drizzle schema to Postgres
npm run db:seed # creates admin@example.com / Admin@123
npm run dev
1 2 3 4 5
docker-compose up -d
# API: http://localhost:3001
# Swagger: http://localhost:3001/api-docs
# Adminer: http://localhost:8080
# Mongo Express: http://localhost:8081
1 2 3
MODULES=auth npm run dev # Auth only, as a standalone service
MODULES=user,auth npm run dev
MODULES=post,comment npm run dev # requires MONGODB_URI
Contact
This is a boilerplate template — replace this section with your own team's support channel (Slack, email alias, issue tracker link, etc.) before shipping it to real users.
Issues & bugs
Point this at your repository's issue tracker.
Support
Point this at your team's support inbox or chat channel.
Docs
See README.md at the project root for the full setup and environment reference.