DEV Community

Cover image for Express app with decorators based routing and dependency injection
Oleksandr Demian
Oleksandr Demian

Posted on

Express app with decorators based routing and dependency injection

When building modern Node.js and Express applications, managing routing and dependency injection can become increasingly complex. Handling controllers, services, and middleware without clean separation often leads to code that is harder to maintain. However, using the right tools and design patterns, we can significantly simplify this process.

In this article, I’ll walk you through how to build a Node.js application with decorators-based routing and dependency injection using the @lemondi library.

Why Use Decorators and Dependency Injection?

Decorators are a powerful feature in TypeScript and JavaScript that allow you to add metadata to classes, methods, and properties. With decorators, we can annotate routing methods, define their HTTP methods, and handle dependency injection without writing complex boilerplate code.

Dependency injection (DI) helps manage how services are instantiated and injected into other components. It decouples the components from each other, making your application more modular and testable. In our case, we’ll use DI for services like database connections and routing.

The @lemondi library simplifies the process by automating DI, handling decorators, and reducing the need for boilerplate code. Let’s dive into how it works!


1. Project Setup

Before we start building, let’s ensure we have the required libraries installed:

npm init -y
npm install express reflect-metadata @lemondi/core @lemondi/scanner typeorm sqlite3 class-transformer
npm install --save-dev typescript @types/node @types/express
Enter fullscreen mode Exit fullscreen mode

Next, let’s set up TypeScript by creating a tsconfig.json file:

{
  "compilerOptions": {
    "lib": ["es5", "es6", "dom"],
    "target": "es5",
    "module": "commonjs",
    "moduleResolution": "node",
    "outDir": "./dist",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
Enter fullscreen mode Exit fullscreen mode

This configuration enables TypeScript's support for decorators and metadata reflection, which is required by the @lemondi library.


2. Decorators for Routing and Injection

routing.ts - Creating the Decorators

First, let’s create two decorators: one for classes (@Router) to define a router, and another for methods (@Route) to define HTTP routes.

// file: src/decorators/routing.ts
import { createClassDecorator, createMethodDecorator } from "@lemondi/scanner";

// Enum for HTTP methods
export enum HttpMethod {
  GET = "get",
  POST = "post",
  PUT = "put",
  DELETE = "delete",
  PATCH = "patch",
  OPTIONS = "options",
}

// @Router decorator for class routing
export const Router = createClassDecorator<{ path: string }>("Router");

// @Route decorator for method routing
export const Route = createMethodDecorator<{ path: string; method: HttpMethod }>("Route");
Enter fullscreen mode Exit fullscreen mode

Here, we use @lemondi/scanner's createClassDecorator and createMethodDecorator to simplify the creation of decorators for routing.


3. Defining the Data Source

datasource.ts - The DataSource Factory

We’ll need a way to create and inject a DataSource (e.g., for connecting to a database). This is where the @lemondi library's @Factory and @Instantiate decorators come into play.

// file: src/factories/datasource.ts
import { Factory, FilesLoader, Instantiate } from "@lemondi/core";
import { DataSource } from "typeorm";

@Factory()
export class DataSourceFactory {
  @Instantiate({ qualifiers: [DataSource] })
  async createDatasource() {
    const ds = new DataSource({
      type: "sqlite",
      database: ":memory:",
      synchronize: true,
      entities: [FilesLoader.buildPath(__dirname, "..", "models", "*.entity.{js,ts}")],
    });

    await ds.initialize();
    return ds;
  }
}
Enter fullscreen mode Exit fullscreen mode

The @Factory decorator marks DataSourceFactory as a provider of components, while @Instantiate marks the createDatasource method as a provider for the DataSource component. DI will automatically resolve and inject the required DataSource.


4. Defining the Entity

user.entity.ts - A TypeORM Entity

Here’s a simple TypeORM entity to define a User model.

// file: src/models/user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { plainToClass } from "class-transformer";

@Entity({ name: "users" })
export class User {
  @PrimaryGeneratedColumn("uuid")
  id?: string;

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  static fromJson(json: User) {
    return plainToClass(User, json);
  }
}
Enter fullscreen mode Exit fullscreen mode

This entity represents a User in the database with fields firstName and lastName. We also provide a utility function fromJson to easily convert JSON data to an instance of the User class.


5. Creating the Router

UsersRouter.ts - Defining Routes

With the decorators in place, we can now define our UsersRouter class to handle user-related routes.

// file: src/routers/UsersRouter.ts
import { HttpMethod, Route, Router } from "../decorators/routing";
import { UsersService } from "../services/UsersService";
import { Request } from "express";
import { User } from "../models/user.entity";

@Router({ path: "/users" })
export class UsersRouter {
  constructor(private readonly usersService: UsersService) {}

  @Route({ path: "/", method: HttpMethod.GET })
  getUsers() {
    return this.usersService.find();
  }

  @Route({ path: "/", method: HttpMethod.POST })
  createUser(req: Request) {
    const data = User.fromJson(req.body);
    return this.usersService.save(data);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, the @Router decorator defines the base path /users, and the @Route decorators handle GET and POST methods for retrieving and creating users.


6. Service Layer

UsersService.ts - Handling Business Logic

We define the service that interacts with the database.

// file: src/services/UsersService.ts
import { Component } from "@lemondi/core";
import { DataSource, Repository } from "typeorm";
import { User } from "../models/user.entity";

@Component()
export class UsersService {
  private repository: Repository<User>;

  constructor(dataSource: DataSource) {
    this.repository = dataSource.getRepository(User);
  }

  save(user: User) {
    return this.repository.save(user);
  }

  find() {
    return this.repository.find();
  }
}
Enter fullscreen mode Exit fullscreen mode

The UsersService class is decorated with @Component(), and its constructor automatically injects the DataSource instance. This allows the service to perform database operations without any manual instantiation.


7. Bootstrapping the Application

app.ts - Putting Everything Together

Finally, we initialize the application using the @lemondi DI system and bind routes dynamically.

// file: src/app.ts
import "reflect-metadata";
import { Component, FilesLoader, instantiate, OnInit, start } from "@lemondi/core";
import * as express from "express";
import { findClassDecorators, findMethodDecorators, scan } from "@lemondi/scanner";
import { Route, Router } from "./decorators/routing";

@Component()
class App {
  @OnInit()
  async onStart() {
    const server = express();
    server.use(express.json());

    const routers = scan(Router);

    for (const router of routers) {
      const routerInstance = await instantiate(router);
      const [routerDecorator] = findClassDecorators(router, Router);

      for (const prop of Reflect.ownKeys(router.prototype)) {
        const [props] = findMethodDecorators(router, prop, Route);
        if (props) {
          const url = routerDecorator.decoratorProps.path + props.decoratorProps.path;
          server[props.decoratorProps.method](url, async (...args) => {
            const result = await Promise.resolve(routerInstance[prop].call(routerInstance, ...args));
            args[1].json(result).end();
          });
        }
      }
    }

    server.listen(3000);
  }
}

start({
  importFiles: [
    FilesLoader.buildPath(__dirname, "factories", "**", "*.js"),
    FilesLoader.buildPath(__dirname, "routers", "**", "*.js"),
  ],
  modules: [App],
});
Enter fullscreen mode Exit fullscreen mode

Here, we use the @OnInit decorator to initialize the Express server after the application is instantiated. We dynamically scan for @Router and @Route decorators and configure routes on the server.

You can now run the app using the following command:

tsc && node ./dist/app.js
Enter fullscreen mode Exit fullscreen mode

Conclusion

Using decorators and the DI system provided by @lemondi, we’ve simplified our Node.js and Express application. This approach abstracts away much of the boilerplate code typically required for routing and dependency management, leading to cleaner, more maintainable code.

If you’re tired of manually configuring routing and services, this pattern is definitely worth exploring. By using decorators, we can keep the code more declarative, readable, and modular.

Top comments (0)