DEV Community

Sandip Gyawali
Sandip Gyawali

Posted on

Writing Scalable Express.js Backends with the Dependency Inversion Principle.

In this article, I’ll explore how the Dependency Inversion Principle (DIP) can be applied to build efficient and scalable Node.js backend systems.

Recently, I set out to write a clean and maintainable Express.js backend using the MVC (Model-View-Controller) architecture, incorporating some of the SOLID principles along the way. During that process, I stumbled upon the Dependency Inversion Principle and realized how powerful it can be—especially when working with unopinionated frameworks like Express.js.

In this post, I’ll break down what Dependency Inversion is, how it fits into the architecture of a Node.js backend, and how it can help us write more modular, testable, and flexible code.

Let’s dive in!

First initialize the package.json in the file as

mkdir express-di-project && npm init
Enter fullscreen mode Exit fullscreen mode

Make sure you have node and npm installed in your system.

Install the packages and dependencies required for the Application.

npm install tsyringe typedi express
Enter fullscreen mode Exit fullscreen mode

After Installing we get the package.json file as
follows

{
    "name": "express-di-project",
    "version": "1.0.0",
    "description": "A Node Server",
    "keywords": [
        "js",
        "ts",
        "typescript",
        "javascript",
        "express",
        "node"
    ],
    "type": "module",
    "scripts": {
    },
    "dependencies": {
        "express": "^5.1.0",
        "tsyringe": "^4.10.0",
        "typedi": "^0.10.0"
    },
    "devDependencies": {
        }
}
Enter fullscreen mode Exit fullscreen mode

Up-to this step the step and configuration part has been completed. Now we focus on the modules that can be used to implement the Dependency Inversion Principle.

Add the tsyringe and typedi modules to the helpers/helpers.di.js file in the project.

// dependency injection modules
import {
  autoInjectable,
  inject,
  injectAll,
  container,
  registry,
} from "tsyringe";
export { Service as Model, Container as Context } from "typedi";
export { Router } from "express";

export const Container = container;
export const Service = autoInjectable;
export const Controller = autoInjectable;
export const Injectable = autoInjectable;
export const Route = autoInjectable;
export const Inject = inject;
export const InjectAll = injectAll;
export const Module = registry;
Enter fullscreen mode Exit fullscreen mode

Here,
1.Service / Injectable / Controller / Route
→ All are aliases for autoInjectable from tsyringe.
Automatically injects dependencies into the constructor of a class.
No need to manually register or resolve them.

@Injectable()
class UserService {
  constructor(private repo: UserRepo) {}
}

Enter fullscreen mode Exit fullscreen mode

2.Inject
→ Used to manually inject a specific token or dependency into the constructor.
Useful when the type can't be inferred (e.g., interfaces or custom tokens).

constructor(@Inject("CustomRepo") repo: IRepository) {}
Enter fullscreen mode Exit fullscreen mode

3.InjectAll
→ Injects all registered instances matching a token.
Great for plugin systems or processing chains.

constructor(@InjectAll("Handlers") private handlers: Handler[]) {}
Enter fullscreen mode Exit fullscreen mode

4.Module
→ Alias for registry from tsyringe.
Registers a group of classes or instances for DI in one go.

@Module([
  { token: "UserRepo", useClass: UserRepository },
  { token: "Logger", useValue: console },
])
class AppModule {}

Enter fullscreen mode Exit fullscreen mode
  1. Container → The global tsyringe container instance. Manages all registered dependencies.
Container.resolve(UserService); // Manual resolution
Enter fullscreen mode Exit fullscreen mode

After a brief understanding of the modules we go for the implementation section.

  • We divide implementation in 3 parts:
    1. Defining a class with a service/auto-Injectable.
    2. Using the defined class as a injection for the other class.
    3. storing the instances in the registry and managing through the container/state.

Step 1:
-> Define a class/service-class and add the decorators to the class.

import { Service } from "../helpers/helper.di.ts";

@Service()
export class PingService {
  constructor() {}

  async ping() {
    const metaData: Record<string, unknown> = {
      message: "Server Connected",
      status: HttpStatusCode.OK,
    };

    return metaData;
  }
}

Enter fullscreen mode Exit fullscreen mode

Step 2:
-> Using the Service() instance as the dependency in the other class. Here, we inject the class so that during the run time the application pulls the instance from the container/state.

import type { Request, Response } from "express";
import { PingService } from "../services/service.ping.ts";
import { Controller, Inject } from "~/helpers/helper.di.ts";
import { catchAsync } from "../middlewares/catch-async.ts";

@Controller()
export class PingController {
  constructor(@Inject("PingService") private service: PingService) {}

  public ping = catchAsync(async (req: Request, res: Response) => {
    const response = await this.service.ping();
    res.status(200).json(response);
  });
}

Enter fullscreen mode Exit fullscreen mode

Step 3:
-> Defining a module which acts as a registry for the application in the project.

import { PingController } from "../controllers/ping.controller.ts";
import { Inject, Injectable, Module } from "../helpers/helper.di.ts";
import { PingService } from "../services/service.ping.ts";

@Module([
  {
    token: "PingService",
    useClass: PingService,
  },
  {
    token: "PingController",
    useClass: PingController,
  },
])
@Injectable()
export class PingModule {
  constructor(@Inject("PingRoute") public route: PingRoute) {}
}

Enter fullscreen mode Exit fullscreen mode

Now, Using the instance of the services and controllers via the container/state in the application.

import "reflect-metadata";
import express, { Router, type Application } from "express";
import { Container, Injectable } from "./helpers/helper.di.ts";

@Injectable()
class App {
  private app: Application;

  constructor() {
    this.app = express();
    this.main();
  }

  private main(): void {
    this.middlewares();
    this.config();
    this.routes();
  }

  private config(): void {
    this.app.disable("x-powered-by");
  }

  private middlewares() {
    this.app.use(express.json());
    this.app.use(
      express.urlencoded({
        extended: true,
      })
    );
  }

  private routes(): void {
    this.app.use("/ping", Container.resolve<Router>("PingModule"));

  public listen() {
    this.app.listen(3000, (err) => {
      if (err) {
        process.exit(1);
      }
      console.log(`Listening to the server ${3000}`);
    });
  }
}

(function () {
  Container.resolve<App>(App).listen();
})();

Enter fullscreen mode Exit fullscreen mode

Here, the container is the state from which we are getting the singleton instance of the controller, module and services.

TLDR;

So, By this approach we a implement scalable architecture in the nodejs/expressjs application.

Let's Connect

If you enjoyed this post and would like to stay updated, feel free to connect.

Github: https://github.com/SandipGyawali
LinkedIn: https://www.linkedin.com/in/sandip-gyawali-615681211/
Twitter: https://x.com/SandipGyawali3

Top comments (5)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.