DEV Community

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

Posted on โ€ข Edited 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.

Billboard image

Use Playwright to test. Use Playwright to monitor.

Join Vercel, CrowdStrike, and thousands of other teams that run end-to-end monitors on Checkly's programmable monitoring platform.

Get started now!

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

๐Ÿ‘‹ Kindness is contagious

Explore a sea of insights with this enlightening post, highly esteemed within the nurturing DEV Community. Coders of all stripes are invited to participate and contribute to our shared knowledge.

Expressing gratitude with a simple "thank you" can make a big impact. Leave your thanks in the comments!

On DEV, exchanging ideas smooths our way and strengthens our community bonds. Found this useful? A quick note of thanks to the author can mean a lot.

Okay