devConsulting.blog

Creating a Simple Dependency Injection Container in Node.js with TypeScript

Dec 04, 2024
Dependency Injection (DI) is a design pattern that helps manage dependencies between objects in an application, making your code cleaner, more modular, and easier to test. In Node.js, building a simple DI container allows you to inject dependencies when needed, promoting loose coupling between components and improving overall flexibility.In this article, we'll walk through the process of creating a basic DI container in Node.js using TypeScript.Why Dependency Injection?
Traditionally, when objects depend on other objects (like services or utilities), they often instantiate those dependencies themselves. This leads to tight coupling, making your code harder to maintain and test. DI solves this problem by:

1. Decoupling: Dependencies are provided (injected) by an external entity, making components more modular.
2. Easier Testing: You can mock or replace dependencies easily in tests.
3. Improved Maintainability: Dependencies can be updated, and new ones can be introduced without changing the dependent classes.
4. Type Safety: You get strong typing, making your code more predictable and reducing runtime errors.
5. Better IntelliSense: IDEs like VSCode provide better auto-completion and error checking when types are explicitly defined.

Step-by-Step: Building the DI Container
Let’s start with a simple DI container that can register services and resolve them.

1. Create a Basic DI Container

/// container.ts
class Container {
  constructor() {}
  private services = new Map<string, any>();

  register<T>(key: string, ObjectOfClassT: T) {
    this.services.set(key, ObjectOfClassT);
  }

  resolve<T>(key: string): T {
    const obj = this.services.get(key);

    if (!obj) {
      throw new Error(`Service not found: ${key}`);
    }

    return obj;
  }
}

export const container = new Container();


Here’s what this TypeScript code does:

register(key, service): Registers a service, basically an object of the service and a key by which that object can be retrieved when needed.
resolve(key): Resolves the service by looking it up, and return an instance of the service.

2. Define some services
///service.ts
export class LoggerService {
  log(message: string): void {
    console.log(`[LoggerService] ${message}`);
  }
}

export class UserService {
  constructor(private logger: LoggerService) {}

  getUser() {
    this.logger.log('Fetching user...');
    return { name: 'John Doe', age: 30 };
  }
}


In this example:

LoggerService provides logging functionality.
UserService depends on LoggerService to log activities.

3. Register and Resolve Services

Now we’ll register these services with the DI container and resolve them.

///app.ts
import { container } from "./container.ts";
import { LoggerService, UserService } from './service.ts';

// Register services
container.register<LoggerService>('loggerService', new LoggerService());
container.register<UserService>('userService', new UserService(container.resolve<LoggerService>("loggerService")));

// Resolve the userService and use it
const userService = container.resolve<UserService>('userService');
const user = userService.getUser();
console.log(`User: ${user.name}, Age: ${user.age}`);


How It Works:

1. We register the LoggerService and UserService in the DI container.
2. We specify that UserService depends on LoggerService by passing 'loggerService' as a dependency.
3. The container resolves userService. While doing so, it recognizes that UserService depends on LoggerService and injects an instance of LoggerService into UserService.
4. Once resolved, we can call getUser() on userService, and it logs a message using the LoggerService.

Conclusion

In this article, we created a simple DI container in TypeScript for a Node.js application. This implementation allows you to:

Register services with or without dependencies.
Automatically resolve services and inject their dependencies.

Using DI promotes clean code, easier testing, and better separation of concerns. In larger projects, you can extend this container with more advanced features such as singleton services, lazy loading, or even asynchronous dependencies.