DEV Community

Cover image for Creating Typescript app with decorator-based dependency injection ๐Ÿ’‰
Oleksandr Demian
Oleksandr Demian

Posted on

Creating Typescript app with decorator-based dependency injection ๐Ÿ’‰

As a huge fan of Node.js and TypeScript, I love how these technologies offer a fast and flexible approach to building applications. However, this flexibility can be a double-edged sword. Code can quickly become messy, which leads to a decline in maintainability and readability over time.

Having worked extensively with Spring (Java) and NestJS (TypeScript), Iโ€™ve come to realize that Dependency Injection (DI) is a powerful pattern for maintaining code quality in the long term. With this in mind, I set out to explore how I could create a TypeScript library that would serve as a foundation for Node.js projects. My goal was to create a library that enforces a component-based development approach while remaining flexible and easily extensible for various use cases. This is how I came up with ๐Ÿ‹ Lemon DI.

How it Works

The core idea behind Lemon DI is quite similar to NestJS (though with different naming conventions). All classes decorated with @Component automatically become injectable components, while non-class entities (e.g., types or interfaces) can be instantiated using factories (@Factory) and injected using unique tokens.

Letโ€™s walk through an example where we integrate with an SQLite database using TypeORM.

Setting Up

Start by installing the required dependencies:

npm install @lemondi/core reflect-metadata sqlite3 tsc typeorm typescript class-transformer
Enter fullscreen mode Exit fullscreen mode

Since TypeORM is an external library, weโ€™ll create the data source using a factory:

// factory/datasource.ts
import {Factory, FilesLoader, Instantiate} from "@lemondi/core";
import {DataSource} from "typeorm";

// @Factory decorator marks this class as a provider of components through functions
@Factory()
export class DataSourceFactory {
    // @Instantiate decorator marks this function as a provider of a component
  @Instantiate({
    qualifiers: [DataSource] // This tells DI that this function creates a DataSource component
  })
  // This is an async function, which means the DI system will wait for it to resolve before using the component
  async createDatasource() {
    // create DataSource instance
    const ds = new DataSource({
      type: "sqlite", // use sqlite for simplicity, but this works perfectly with any other DB
      database: ":memory:",
      synchronize: true, // Automatically create tables on startup
      // load all models
      entities: [FilesLoader.buildPath(__dirname, "..", "models", "*.entity.{js,ts}")],
    });

    await ds.initialize(); // Initialize the DataSource before using it
    return ds;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have our DataSource component, letโ€™s define a model and a service to interact with it:

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

// This is a standard TypeORM entity declaration
@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
// services/UsersService.ts
import {Component} from "@lemondi/core";
import {DataSource, Repository} from "typeorm";
import {User} from "../models/user.entity";

// This class is marked as component, it will automatically map itself during the dependency injection step
@Component()
export class UsersService {
  private repository: Repository<User>;

  // The component constructor is where the dependency injection happens
  // For each argument, the DI system will look for a component and provide it (the components are instantiated automatically when needed)
  constructor(
    // Here we tell DI system that we need DataSource instance (which is exported from our factory)
    // It is completely transparent for us that the DataSource component is async
    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

Now that we have our DB and Users service in place we can start our app:

import "reflect-metadata"; // this is required to emit classes metadata

import {Component, FilesLoader, OnInit, start} from "@lemondi/core";
import {UsersService} from "./services/users";
import {User} from "./models/user.entity";

@Component()
class App {
  constructor(
    private usersService: UsersService,
  ) { }

  // @OnInit decorator only works for components directly imported in `start`
  // @OnInit decorator tells the system to execute this method after the component is instantiated
  @OnInit()
  async onStart() {
    // create a new entry
    const user = User.fromJson({
      lastName: "Last",
      firstName: "First",
    });

    // save user in DB
    await this.usersService.save(user);

    // fetch user from DB
    const users = await this.usersService.find();
    console.log(users); // will print data fetched from DB
  }
}

// start method is required to start the app
start({
  importFiles: [
    // since there is no need to reference factories in the code, we need to tell our DI system to import those files to make sure they are accessible
    FilesLoader.buildPath(__dirname, "factories", "**", "*.js"),
  ],
  modules: [App], // The entry point; classes listed here will be instantiated automatically
});
Enter fullscreen mode Exit fullscreen mode

TypeScript Configuration

To enable decorators and ensure everything works as expected, add the following to your tsconfig.json:

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

Finally, run the following command to compile and execute the app:

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

Final thoughts

โš ๏ธ Important: Please note that this library is still in its early stages and should not be used in production applications yet. Itโ€™s a prototype I created for fun and exploration of decorators in TypeScript. You can find full example code here.

Top comments (0)