Skip to main content

Implementation of IoC in TypeScript

· 7 min read
Ivan Tsai
Backend engineer

Recently, I introduced an Inversion of Control (IoC) container into a new project and found that most container libraries only provide documentation related to their own usage. There are fewer examples of actual implementations. In this article, we will use a well-known open-source project as an example to organize several good practices and the basic rules that should be followed.

IoC Containers

There are many excellent containers available for use. If you are using frameworks like Angular or Nest.js, they already have built-in container/DI functionality. Here are a few popular containers:

info

Instead of introducing containers, it is better to prioritize modifying existing code to comply with the IoC model. 👍

Good Practices

After starting to use a container, there are some rules that we can refer to. They may not be considered best practices since the nature of each project and the habits of the team members can vary. However, following these rules can help us avoid some unnecessary complications. 😊

Use the Composition Root to Avoid Service Locator

Now that we have a container, we only need to ensure that each service can access the same container to resolve dependency issues, right? Let's say we have an OrderProcess class that depends on OrderValidator:

// Anti pattern!

// container.ts
class Container {
public bind(name: string, instance) {
/* ... */
}
public get(name: string) {
/* ... */
}
}

export default new Container(); // Export as a global singleton

// orderProcess.ts
import container from "./container";
class OrderProcess {
public process(order: Order) {
const validator = container.get("OrderValidator");
validator.validate(order);
}
}

This pattern is known as the Locator Pattern. It may seem fine, but it is considered an anti-pattern[2][3] for the following reasons:

  1. Unclear API usage: Another developer who wants to use OrderProcess without inspecting its internal code may write it like this:

    import { OrderProcess } from "./orderProcess";
    const orderProcess = new OrderProcess();
    orderProcess.process(order); // Service not found error

    This would result in a runtime error of not finding OrderValidator because we didn't register it in the container. Unlike direct constructor injection, we cannot explicitly know which services OrderProcess depends on.

  2. Maintenance difficulties: Assume that we want to add the functionality to store orders within the process function:

    class OrderProcess {
    public process(order: Order) {
    const validator = container.get("OrderValidator");
    validator.validate(order);
    const storage = container.get("OrderStorage");
    storage.save(order);
    }
    }

    It's easy to make this change and add another storage service. However, would our modifications cause breaking changes? We don't know 😖; We have no way of knowing if the people using it have registered OrderStorage in their containers.

Therefore, we should compose and resolve components in a unified place—the Composition Root. Typically, this would be implemented at the entry point[4] of the program.

Without using a container:

// OrderProcess.ts
class OrderProcess {
constructor(private validator: OrderValidator) {}

public process(order: Order) {
this.validator.validate(order);
}
}
// main.ts
class Main {
public main() {
const orderValidator = new OrderValidator();
const orderProcess = new OrderProcess(orderValidator);
orderProcess.process(order);
}
}

Using a container:

// OrderProcess.ts
class OrderProcess {
constructor(@Inject("OrderValidator") private validator: OrderValidator) {}

public process(order: Order) {
this.validator.validate(order);
}
}
// main.ts
class Main {
public main() {
const container = new Container();
container.bind("OrderValidator", OrderValidator);
container.bind("OrderProcess", OrderProcess);
const orderProcess = container.get("OrderProcess");
orderProcess.process(order);
}
}

Avoiding excessive injection

When we find that a service has excessive injection, it usually means that it is doing too much and violates the Single Responsibility Principle. In such cases, it is recommended to refactor the service and separate its responsibilities.

Avoid injecting "data" directly

We should strive to inject services rather than data itself. For example, if we need a time parameter, we should not directly inject a Date object but instead inject a service that provides time parameters. This approach allows for more flexibility and easier testing.

Avoid direct injection of classes

Directly injecting classes creates tight coupling between services. Instead, we should aim for abstraction through interfaces.

Implementation Examples

I have referred to an open-source project, javascript-obfuscator, which uses Inversify as a container. It contains some interesting designs that may violate the principles mentioned above. Please refer to it for further information.

Configuration injection

In our codebase, we often have various configurations, such as server addresses or file paths. Different projects have their own methods of handling configurations. For example, in .NET Core, the Options pattern[6][7] is provided, and reading environment variables directly is another approach. We can create an Options service to store and validate these configuration parameters:

// Option class
class Options implements IOptions {
@IsBoolean() // Validation - https://github.com/typestack/class-validator
public readonly unicodeEscapeSequence!: boolean

constructor(
// Input object
@inject(ServiceIdentifiers.TInputOptions) inputOptions: TInputOptions,
) {
const optionsPreset: TInputOptions = Options.getOptionsByPreset(
inputOptions.optionsPreset ?? OptionsPreset.Default
);

Object.assign(this, optionsPreset, inputOptions);

const errors: ValidationError[] = validateSync(this, Options.validatorOptions);

if (errors.length) {
throw new ReferenceError(`Validation failed. errors:\n${ValidationErrorsFormatter.format(errors)}`);
}
}
}

// Binding
const optionsModule: interfaces.ContainerModule = new ContainerModule((bind: interfaces.Bind) => {
// bind input
bind<TInputOptions>(ServiceIdentifiers.TInputOptions)
.toDynamicValue(() => options)
.inSingletonScope();
// bind option class
bind<IOptions>(ServiceIdentifiers.IOptions)
.to(Options)
.inSingletonScope();
});

// Usage
class XXXService {
constructor(@inject(ServiceIdentifiers.Options))
}

In this example, we use InputOptions and Options bindings. InputOptions is only used for inputting data to the Options service, while other services need to directly inject the Options service when they require configuration parameters.

info

As a project grows, the number of configurations typically increases. Putting all configurations into a single Options service can make testing cumbersome. For example, if a WebService only needs address and port information, during testing, we still need to mock other service's configurations like filePath because we need to assemble a complete Options service. In such cases, it can be beneficial to group configurations appropriately, such as WebServerOptions, etc.

Injecting a factory

When we need to decide which service to use at runtime based on certain conditions, such as connecting to Azure Storage or AWS S3 depending on different configurations, we can inject a factory instead of directly injecting a service. The following example provides two different loaders, A and B, and determines which one to use at runtime based on options.loader.

class ALoader implements Loader {}
class BLoader implements Loader {}

bind(Loader).to(ALoader).withName("A");
bind(Loader).to(BLoader).withName("B");

bind(LoaderFactory).to((name) => {
return container.getWithName(TYPES.Loader, name);
});
// Usage
class App {
constructor(@inject(Loadfactory) factory, @inject(Options) options) {
this.loader = factory(options.loader);
}
}

Note 1: Inversify provides Auto named factory, eliminating the need to implement the factory ourselves.
Note 2: This approach is sometimes considered a variation of the Locator pattern[5]. Please consider the pros and cons for your specific case.

Modules

When we have a large number of services, it is a good idea to group them into modules. This allows us to load and unload a group of services without worrying about missing any dependencies. Inversify already implements the functionality of ContainerModule.

// AnalyzersModule.ts
export const analyzersModule: interfaces.ContainerModule = new ContainerModule((bind: interfaces.Bind) => {
// calls graph analyzer
bind<ICallsGraphAnalyzer>(ServiceIdentifiers.ICallsGraphAnalyzer)
.to(CallsGraphAnalyzer)
.inSingletonScope();

// number numerical expression analyzer
bind<INumberNumericalExpressionAnalyzer>(ServiceIdentifiers.INumberNumericalExpressionAnalyzer)
.to(NumberNumericalExpressionAnalyzer)
.inSingletonScope();

// prevailing kind of variables analyzer
bind<IPrevailingKindOfVariablesAnalyzer>(ServiceIdentifiers.IPrevailingKindOfVariablesAnalyzer)
.to(PrevailingKindOfVariablesAnalyzer)
.inSingletonScope();
...
}
// Load
this.container.load(analyzersModule);
// Unload
this.container.unload(analyzersModule);

Facade

In addition to directly exposing the container, we can create a Facade class to make it easier for others to use our code.

class InversifyContainerFacade {
public load(options: TInputOptions): void {
this.container
.bind<TInputOptions>(ServiceIdentifiers.TInputOptions)
.toDynamicValue(() => options)
.inSingletonScope();
this.container.load(analyzersModule);
// ...
}
}

References

  1. Inversify - Good practices
  2. Mark Seemann - Service Locator is an Anti-Pattern
  3. Manning's focus - The Service Locator Anti-Pattern
  4. Mark Seemann - Composition Root
  5. Microsoft - Dependency injection guidelines
  6. Microsoft - Options pattern in ASP.NET Core
  7. Marcin Dąbrowski - asp.net Options - why You should not use it