erojas.devertek.io logo
Building a Scalable Express Server with Object-Oriented Programming

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:

  • Separate concerns

  • Reuse controller logic

  • Easier testing

  • Clearer structure as the app grows

Project Structure

1src/
2├── app.ts              # Main application class
3├── index.ts            # Entry point
4├── config/
5│   └── env.ts         # Environment configuration
6├── controllers/
7│   └── health.ts      # Controller example
8├── types/
9│   └── controller.ts  # Controller interface
10├── middleware/        # Custom middleware
11├── services/          # Business logic
12└── utils/             # Helper functions

Define the Controller Interface

Start with a Controller interface:

1import { Router } from "express";   
2export default interface Controller {
3    path: string;
4    router: Router;
5}

Create the Main App Class

The App class encapsulates Express setup:

1import express from "express";
2import { ALLOWED_ORIGINS } from "./config/env";
3import cors from "cors";
4import Controller from "./types/controller";
5class App {
6  public app: express.Application;
7  public port: number;
8  constructor(port: number, controllers: Controller[]) {
9    this.app = express();
10    this.port = port;
11    this.init(controllers);
12  }
13  private  init(controllers: Controller[]) {
14    try {
15      this.initializeMiddleWare();
16      this.initializeCORS();
17      this.initializeControllers(controllers);
18    } catch (error) {
19      console.error("Error during initialization", error);
20      process.exit(1);
21    }
22  }
23  private initializeControllers(controllers: Controller[]) {
24    controllers.forEach((controller) => {
25      this.app.use(controller.path, controller.router);
26    });
27  }
28  private initializeMiddleWare() {
29    this.app.use(express.json());
30    this.app.use(express.urlencoded({ extended: true }));
31  }
32  private initializeCORS() {
33    const corsOptions = {
34      origin: (
35        origin: string | undefined,
36        callback: (err: Error | null, allow?: boolean) => void
37      ) => {
38        if (!origin || ALLOWED_ORIGINS.includes(origin)) {
39          callback(null, true);
40        } else {
41          console.warn(`CORS blocked request from origin:${origin}`);
42          callback(null, false);
43        }
44      },
45      methods: ["GET", "POST", "PATCH", "DELETE"],
46      credentials: true,
47    };
48    this.app.use(cors(corsOptions));
49  }
50  public listen():void {
51    this.app.listen(this.port, () => {
52        console.log(`API listening on http://localhost:${this.port}`)
53    })
54  }
55}
56
  1. Notes:

  2. Encapsulates Express setup

  3. Initializes middleware, CORS, and controllers

  4. Uses the controller's path for mounting

  5. Separates concerns with private methods

  6. Create your first controller

  7. Set up environment configuration

  8. Wire everything together

1import 'dotenv/config';
2import App from './app';  
3import HealthController from './controllers/health';
4import { PORT } from './config/env';
5const app = new App(PORT, [new HealthController()]);
6app.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.