Express apps often start with a flat index.ts. This post shows how to structure the server with OOP for maintainability and scalability.
Why OOP for Express?
OOP helps:
Project Structure
src/
├── app.ts # Main application class
├── index.ts # Entry point
├── config/
│ └── env.ts # Environment configuration
├── controllers/
│ └── health.ts # Controller example
├── types/
│ └── controller.ts # Controller interface
├── middleware/ # Custom middleware
├── services/ # Business logic
└── utils/ # Helper functions
Define the Controller Interface
Start with a Controller interface:
import { Router } from "express";
export default interface Controller {
path: string;
router: Router;
}
Create the Main App Class
The App class encapsulates Express setup:
import express from "express";
import { ALLOWED_ORIGINS } from "./config/env";
import cors from "cors";
import Controller from "./types/controller";
class App {
public app: express.Application;
public port: number;
constructor(port: number, controllers: Controller[]) {
this.app = express();
this.port = port;
this.init(controllers);
}
private init(controllers: Controller[]) {
try {
this.initializeMiddleWare();
this.initializeCORS();
this.initializeControllers(controllers);
} catch (error) {
console.error("Error during initialization", error);
process.exit(1);
}
}
private initializeControllers(controllers: Controller[]) {
controllers.forEach((controller) => {
this.app.use(controller.path, controller.router);
});
}
private initializeMiddleWare() {
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
}
private initializeCORS() {
const corsOptions = {
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void
) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) {
callback(null, true);
} else {
console.warn(`CORS blocked request from origin:${origin}`);
callback(null, false);
}
},
methods: ["GET", "POST", "PATCH", "DELETE"],
credentials: true,
};
this.app.use(cors(corsOptions));
}
public listen():void {
this.app.listen(this.port, () => {
console.log(`API listening on http://localhost:${this.port}`)
})
}
}
Notes:
Encapsulates Express setup
Initializes middleware, CORS, and controllers
Uses the controller's path for mounting
Separates concerns with private methods
Create your first controller
Set up environment configuration
Wire everything together
import 'dotenv/config';
import App from './app';
import HealthController from './controllers/health';
import { PORT } from './config/env';
const app = new App(PORT, [new HealthController()]);
app.listen();
This approach keeps the entry point lightweight while maintaining a clear architectural structure. By instantiating the App with its port and controllers, the server startup remains concise and focused on initialization rather than configuration. As the project grows, new controllers can be introduced without modifying core files, ensuring a scalable and maintainable system. Each controller operates independently, making the application easier to test and reason about. Combined with TypeScript’s strong type safety, this structure enforces clear boundaries between configuration, business logic, and routing—resulting in a clean, modular, and professional Express setup.