Implementation of IoC in TypeScript
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:
- InversifyJS (the container used in this article)
- TypeDI
- tsyringe
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:
-
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 errorThis 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 servicesOrderProcess
depends on. -
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.
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
- Inversify - Good practices
- Mark Seemann - Service Locator is an Anti-Pattern
- Manning's focus - The Service Locator Anti-Pattern
- Mark Seemann - Composition Root
- Microsoft - Dependency injection guidelines
- Microsoft - Options pattern in ASP.NET Core
- Marcin Dąbrowski - asp.net Options - why You should not use it