Simplifying Dependency Injection in TypeScript with Injected-ts
Introduction
Dependency injection is a powerful technique that promotes modular and loosely coupled code by managing the dependencies of an application and facilitating their injection into classes. In TypeScript, implementing dependency injection can sometimes be cumbersome and time-consuming. The main drawback of existing library is how they are coupled to your code, by using decorators, or force you to add some code in your production code to enable DI.
However, with the advent of Injected-ts, a lightweight dependency injection library, managing dependencies in TypeScript has become easier and more efficient. In this article, we will explore the motivations behind using Injected-ts, delve into its inner workings, and demonstrate how to leverage its features to simplify dependency injection in TypeScript projects.
The code is open source and available here: https://github.com/remihenache/injected-ts
Why Use Injected-ts?
- Modularity and Maintainability: Injected-ts enables you to break down your code into smaller, manageable modules with clear dependencies. By decoupling dependencies from the classes that use them, you can easily replace or modify implementations without affecting other parts of the codebase.
- Testability: With dependency injection, you can easily substitute dependencies with mock objects or test doubles during unit testing. This facilitates more thorough testing, improves test coverage, and enhances overall code quality.
- Flexibility: Injected-ts provides a flexible and intuitive API for registering and resolving dependencies. It supports various lifecycle options, constructor parameter injection, interface injection, and scope management. This flexibility allows you to tailor the dependency injection approach to the specific needs of your project.
The Inner Workings of Injected-ts
At the core of Injected-ts are two main classes: ServiceCollection
and ServiceProvider
. Let's explore how they work together to simplify dependency injection in TypeScript.
ServiceCollection:
- The
ServiceCollection
class serves as a container for registering dependencies. It provides methods such asaddScoped
,addTransient
, andaddSingleton
to register dependencies with different lifecycles. - When registering a dependency, you can use the fluent API provided by
ServiceCollection
to specify the dependency type and its associated configuration, such as constructor dependencies. - The
build
method ofServiceCollection
creates an instance ofServiceProvider
with the registered dependencies.
ServiceProvider:
- The
ServiceProvider
class is responsible for resolving dependencies and managing their lifecycles. - By calling the
get
method onServiceProvider
with the appropriate dependency identifier, you can resolve and retrieve an instance of the requested dependency. ServiceProvider
supports scoped lifecycles, allowing you to create and manage scopes using thestartScope
andendScope
methods. This enables you to control the lifetime of dependencies within specific scopes.
Using Injected-ts in Practice
To demonstrate how to use Injected-ts, let’s consider an example scenario where we have a UserService
that depends on a UserRepository
. Here's how we can implement it using Injected-ts:
Create a ServiceCollection
instance:
import { ServiceCollection } from 'injected-ts';
const services = new ServiceCollection();
Register dependencies:
services.addScoped<UserRepository>((builder) =>
builder.fromType(UserRepository)
);
services.addScoped<UserService>((builder) =>
builder.fromType(UserService).withDependencies(UserRepository)
);ty
Build and use the ServiceProvider
:
const serviceProvider = services.build();
const userRepository = serviceProvider.get<UserRepository>(UserRepository);
const userService = serviceProvider.get<UserService>(UserService);
// Use the dependencies
userService.registerUser('John Doe');
Lifecycles
The library can handle three lifecycle:
- Singleton: one instance for all the application
- Transient: one instance each time you inject it
- Scoped: one instance by scope
You can create your own custom lifecycle if need by implementing the Lifcycle interface, then use the add method.
class CustomLifecycle implements Lifecycle {
// Implement the lifecycle methods
}
services.add<MyService>(new CustomLifecycle(), (builder) =>
builder.fromType(MyService).withDependencies(Dependency1, Dependency2)
);
Use the ServiceProvider inside a class or a factory
Injected-ts provides the possibility to access the service provider instance by injecting it inside a class or a factory:
- Injection
class MyService {
constructor(serviceProvider: ServiceProvider) {}
public doSomething() {
const anotherService = this.serviceProvider.get<AnotherService>(AnotherService.name);
// Do something with the service
}
}
- Factory
services.addSingleton<ServiceDependency>((builder) =>
builder.fromType(ServiceDependency.name).withDependencies(TypeDependency)
);
services.addSingleton<MyService>((builder) =>
builder.fromName('MyService').useFactory((serviceProvider) => new MyService("Some value", serviceProvider.get<ServiceDependency>(ServiceDependency.name)))
);
Conclusion
Injected-ts is a valuable tool for simplifying dependency injection in TypeScript projects. By leveraging its features, such as the fluent API for dependency registration, flexible lifecycle management, and scope support, you can enhance