Skip to main content

Using DI Container

Dependency Injection (DI) is a core aspect of building scalable and maintainable applications. The DI container simplifies the management of object lifecycles and dependencies. This document will guide you through the necessary steps to effectively use the DI container in your projects, including how to register and resolve dependencies, organize them with modules, and manage their lifetimes.

Usage of a DI container involves the following key steps:

  • Registering a dependency: Build a registry of factories for later use.
  • Resolving a dependency: Create instances of controllers, components, or services as needed throughout the application.

It is a common practice to lock down the container after the composition root has been executed to ensure the container is not altered after the initial setup. This practice helps in maintaining a predictable application state.

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

// Register dependencies with builder

return builder.build();
};

This introduction sets the context for the use of the DI container and provides a glimpse into the steps involved.

Register a Dependency

Understanding the registration process begins with setting up a factory function. This function is responsible for creating instances of a dependency upon request. Each dependency is registered using a unique key, which comprises a symbol and TypeScript type, ensuring accurate type resolution and dependency management.

Define a new key and register a dependency like so:

export const todoStateServiceKey = key<TodoStateService>("TodoStateService");

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.register(todoStateServiceKey, () => new TodoStateService());

return builder.build();
};

Organize Dependencies with Modules

Modules play a crucial role in grouping related dependencies that are specific to a feature or domain within the application. This organization aids in maintaining a clean architecture and facilitates easier maintenance. Modules are defined as functions that accept a builder as an argument, where each function is responsible for registering the necessary dependencies.

Add modules to the container builder using the registerModule method like this:

export const todoModule: : Module = builder => {
builder.register(todoRepositoryKey, () => new TodoRepository());
builder.register(todoStateServiceKey, (container) => new TodoStateService(container.resolve(todoRepositoryKey)));
};

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.registerModule(todoModule);

return builder.build();
};

Using modules helps streamline dependency management and ensures a modular and scalable application structure.

Resolve a Dependency

Once dependencies have been registered, the next step is to resolve them when needed. The container uses a method called build to finalize its setup and generate a Resolve interface instance. The Resolve interface provides a resolve method to obtain the required dependency instance.

Here's how you can resolve a dependency using the container:

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.register(todoStateServiceKey, () => new TodoStateService());

return builder.build();
};

const container = createContainer();
const todoStateService = container.resolve(todoStateServiceKey);

Resolve a Dependency with Dependencies

When resolving a dependency that itself has dependencies, the container handles these nested dependencies automatically. This feature simplifies the management of complex dependency relationships, ensuring that all necessary components are instantiated in the correct order.

Here's an illustration of how a dependency with its own dependencies is resolved:

export const todoStateServiceKey = key<TodoStateService>("TodoStateService");
export const todoRepositoryKey = key<TodoRepository>("TodoRepository");

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.register(todoRepositoryKey, () => new TodoRepository());
builder.register(todoStateServiceKey, (container) => new TodoStateService(container.resolve(todoRepositoryKey)));

return builder.build();
};

const container = createContainer();
const todoStateService = container.resolve(todoStateServiceKey); // Resolves TodoStateService with TodoRepository dependency

This method ensures that the TodoStateService not only gets created but also receives a properly instantiated TodoRepository through the container’s resolution process.

Debuggiing
  • When resolving a dependency, the container will throw an error if the dependency is not registered.
  • For tracking down issues, the container provides a logToConsole boolean flag that logs the resolution process to the console.
const container = createContainer();
container.logToConsole = true;
const todoStateService = container.resolve(todoStateServiceKey);

Life Time Management

Effective life time management is crucial for ensuring that dependencies are instantiated and disposed of at appropriate times, which helps in optimizing resource utilization and maintaining application performance.

There are three types of life time scopes in the DI container:

  • Singleton: This scope creates a single instance of a dependency and uses that same instance throughout the application lifecycle.
  • Transient: This scope creates a new instance of a dependency each time it is resolved.
  • Scoped: This scope creates an instance of a dependency once per scope, typically per request in web applications or per React tree node in frontend applications.

Example of Singleton and Transient life time scopes

export const todoStateServiceKey = key<TodoStateService>("TodoStateService");
export const todoRepositoryKey = key<TodoRepository>("TodoRepository");

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.register(
todoRepositoryKey,
() => new TodoRepository(),
LifeTimeScope.Singleton
);
builder.register(
todoStateServiceKey,
(container) => new TodoStateService(container.resolve(todoRepositoryKey)),
LifeTimeScope.Transient);

return builder.build();
};

const container = createContainer();
const todoStateService1 = container.resolve(todoStateServiceKey);
const todoStateService2 = container.resolve(todoStateServiceKey);

// todoStateService1 and todoStateService2 are same instance
console.log(todoStateService1 === todoStateService2); // true

const todoRepository1 = container.resolve(todoRepositoryKey);
const todoRepository2 = container.resolve(todoRepositoryKey);

// todoRepository1 and todoRepository2 are different instances
console.log(todoRepository1 === todoRepository2); // false

Scoped life time

For creating a scope, the container has a method createScope that creates a new scope. The scope is used to manage the life time of dependencies. When the scope is disposed, all dependencies that were created in the scope are disposed.

export const todoStateServiceKey = key<TodoStateService>("TodoStateService");
export const todoRepositoryKey = key<TodoRepository>("TodoRepository");

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.register(
todoRepositoryKey,
() => new TodoRepository(),
LifeTimeScope.Singleton
);
builder.register(
todoStateServiceKey,
(container) => new TodoStateService(container.resolve(todoRepositoryKey)),
LifeTimeScope.Scoped);

return builder.build();
};

const container = createContainer();
const scope1 = container.createScope(Symbol.for("scope1"));
const scope2 = container.createScope(Symbol.for("scope2"));

const todoStateService1 = scope1.resolve(todoStateServiceKey);
const todoStateService2 = scope2.resolve(todoStateServiceKey);

// todoStateService1 and todoStateService2 are different instances
console.log(todoStateService1 === todoStateService2); // false

Understanding these scopes and their appropriate use cases will allow developers to make informed decisions about the lifecycle management of their application’s dependencies.

Dispose Pattern

The dispose pattern is essential for the proper management of resources, especially those not handled by the garbage collector, such as connections and subscriptions. This pattern ensures that resources are released appropriately to prevent memory leaks and other resource-related issues.

In the DI container, disposing of dependencies that implement the Disposable or AsyncDisposable interfaces is handled through specific methods:


type Service = {
// methods
} & Disposable;

const createService = (): Service => {
const subject = new Subject();
const service: Service = {
[Symbol.dispose]() {
// dispose resources
subject.complete();
}
};

return service;
};


export const serviceKey = key<Service>("Service");

export const createContainer = (): Resolve => {
const builder = new ContainerBuilder();

builder.register(serviceKey, () => createService());

return builder.build();
};

const container = createContainer();
const service = container.resolve(serviceKey);

// dispose the container
container.dispose(); // dispose the service

Using the dispose pattern, developers can ensure that every component cleans up after itself, preventing resource wastage and potential application instability. This is particularly important in applications with complex life cycles or those that manage many external resources.

At the next step of the guide, we will explore how to itegrte the DI container with a React application.