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
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;
}
}
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);
}
}
// 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();
}
}
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
});
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
}
}
Finally, run the following command to compile and execute the app:
tsc && node ./dist/app.js
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)