Dependency Injection
Dependency Injection (DI) is a design pattern in which classes and functions receive their dependencies. It follows the principle of Inversion of Control (IoC) and helps to better separate complex code in order to significantly improve testability, modularity and clarity. Although there are other design patterns, such as the service locator pattern, for applying the principle of IoC, DI has established itself as the dominant pattern, especially in enterprise software.
To illustrate the principle of IoC, here is an example:
import { HttpClient } from 'http-library';
class UserRepository {
async getUsers(): Promise<Users> {
const client = new HttpClient();
return await client.get('/users');
}
}
The UserRepository class has an HttpClient as a dependency. This dependency in itself is nothing remarkable, but it is problematic that UserRepository creates the HttpClient itself. This is obvious at first glance, but it has its drawbacks: What if we want to replace the HttpClient? What if we want to test UserRepository in a unit test without allowing real HTTP requests to go out? How do we know that the class even uses an HttpClient?
Inversion Of Control
In the thought of Inversion of Control (IoC) is the following alternative variant that sets the HttpClient as an explicit dependency in the constructor (also known as constructor injection).
class UserRepository {
constructor(
private http: HttpClient
) {}
async getUsers(): Promise<Users> {
return await this.http.get('/users');
}
}
Now UserRepository is no longer responsible for creating the HttpClient, but the user of UserRepository. This is Inversion of Control (IoC). The control has been reversed or inverted. Specifically, this code applies dependency injection, because dependencies are received (injected) and no longer created or requested. Dependency Injection is only one variant of IoC.
Service Locator
Besides DI, Service Locator (SL) is also a way to apply the IoC principle. This is commonly considered the counterpart to Dependency Injection, as it requests dependencies rather than receiving them. If HttpClient were requested in the above code as follows, it would be called a Service Locator pattern.
class UserRepository {
async getUsers(): Promise<Users> {
const client = locator.getHttpClient();
return await client.get('/users');
}
}
The function locator.getHttpClient
can have any name. Alternatives would be function calls like useContext(HttpClient)
, getHttpClient()
, await import("client"),
or a container call like container.get(HttpClient)
. An import of a global is a slightly different variant of a service locator, using the module system itself as the locator:
import { httpClient } from 'clients'
class UserRepository {
async getUsers(): Promise<Users> {
return await httpClient.get('/users');
}
}
All these variants have in common that they explicitly request the HttpClient dependency. This request can happen not only to properties as a default value, but also somewhere in the middle of the code. Since in the middle of the code means that it is not part of a type interface, the use of the HttpClient is hidden. Depending on the variant of how the HttpClient is requested, it can sometimes be very difficult or completely impossible to replace it with another implementation. Especially in the area of unit tests and for the sake of clarity, difficulties can arise here, so that the service locator is now classified as an anti-pattern in certain situations.
Dependency Injection
With Dependency Injection, nothing is requested, but it is explicitly provided by the user or received by the code. As can be seen in the example of Inversion of Control, the dependency injection pattern has already been applied there. Specifically, constructor injection can be seen there, since the dependency is declared in the constructor. So UserRepository must now be used as follows.
const users = new UserRepository(new HttpClient());
The code that wants to use UserRepository must also provide (inject) all its dependencies. Whether HttpClient should be created each time or the same one should be used each time is now decided by the user of the class and no longer by the class itself. It is no longer requested (from the class’s point of view) as in the case of the service locator, or created entirely by itself in the initial example. This inversion of the flow has various advantages:
-
The code is easier to understand because all dependencies are explicitly visible.
-
The code is easier to test because all dependencies are unique and can be easily modified if needed.
-
The code is more modular, as dependencies can be easily exchanged.
-
It promotes the Separation of Concern principle, as UserRepository is no longer responsible for creating very complex dependencies itself when in doubt.
But an obvious disadvantage can also be recognized directly: Do I really need to create or manage all dependencies like the HttpClient myself? Yes and No. Yes, there are many cases where it is perfectly legitimate to manage the dependencies yourself. The hallmark of a good API is that dependencies don’t get out of hand, and that even then they are pleasant to use. For many applications or complex libraries, this may well be the case. To provide a very complex low-level API with many dependencies in a simplified way to the user, facades are wonderfully suitable.
Dependency Injection Container
For more complex applications, however, it is not necessary to manage all dependencies yourself, because that is exactly what a so-called dependency injection container is for. This not only creates all objects automatically, but also "injects" the dependencies automatically, so that a manual "new" call is no longer necessary. There are various types of injection, such as constructor injection, method injection, or property injection. This makes it easy to manage even complicated constructions with many dependencies.
A dependency injection container (also called DI container or IoC container) brings Deepkit in @deepkit/injector
or already ready integrated via app modules in the Deepkit framework. The above code would look like this using a low-level API from the @deepkit/injector
package.
import { InjectorContext } from '@deepkit/injector';
const injector = InjectorContext.forProviders(
[UserRepository, HttpClient]
);
const userRepo = injector.get(UserRepository);
const users = await userRepo.getUsers();
The injector
object in this case is the dependency injection container. Instead of using "new UserRepository", the container returns an instance of UserRepository using get(UserRepository)
. To initialize the container statically a list of providers is passed to the function InjectorContext.forProviders
(in this case simply the classes).
Since DI is all about providing dependencies, the dependencies are provided to the container, hence the technical term "provider". There are various types of providers: ClassProvider, ValueProvider, ExistingProvider, FactoryProvider. All together, they allow very flexible architectures to be mapped with a DI container.
All dependencies between providers are automatically resolved and as soon as an injector.get()
call occurs, the objects and dependencies are created, cached, and correctly passed either as a constructor argument (constructor injection), set as a property (property injection), or passed to a method call (method injection).
Now to exchange the HttpClient with another one, another provider (here the ValueProvider) can be defined for HttpClient:
const injector = InjectorContext.forProviders([
UserRepository,
{provide: HttpClient, useValue: new AnotherHttpClient()},
]);
As soon as UserRepository is requested via injector.get(UserRepository)
, it receives the AnotherHttpClient object. Alternatively, a ClassProvider can be used here very well, so that all dependencies of AnotherHttpClient are also managed by the DI container.
const injector = InjectorContext.forProviders([
UserRepository,
{provide: HttpClient, useClass: AnotherHttpClient},
]);
All types of providers are listed and explained in the Dependency Injection Providers section.
It should be mentioned here that Deepkit’s DI container only works with Deepkit’s runtime types. This means that any code that contains classes, types, interfaces, and functions must be compiled by the Deepkit Type Compiler in order to have the type information available at runtime. See the chapter Runtime Types.
Dependency Inversion
The example of UserRepository under Inversion of Control shows that UserRepository depends on a lower level HTTP library. In addition, a concrete implementation (class) is declared as a dependency instead of an abstraction (interface). At first glance, this may seem to be in line with the object-oriented paradigms, but it can lead to problems, especially in complex and large architectures.
An alternative variant would be to convert the HttpClient dependency into an abstraction (interface) and thus not import code from an HTTP library into UserRepository.
interface HttpClientInterface {
get(path: string): Promise<any>;
}
class UserRepository {
concstructor(
private http: HttpClientInterface
) {}
async getUsers(): Promise<Users> {
return await this.http.get('/users');
}
}
This is called the dependency inversion principle. UserRepository no longer has a dependency directly on an HTTP library and is instead based on an abstraction (interface). It thus solves two fundamental goals in this principle:
-
High-level modules should not import anything from low-level modules.
-
Implementations should be based on abstractions (interfaces).
Merging the two implementations (UserRepository with an HTTP library) can now be done via the DI container.
import { HttpClient } from 'http-library';
import { UserRepository } from './user-repository';
const injector = InjectorContext.forProviders([
UserRepository,
HttpClient,
]);
Since Deepkit’s DI container is capable of resolving abstract dependencies (interfaces) like this one of HttpClientInterface, UserRepository automatically gets the implementation of HttpClient since HttpClient implemented the interface HttpClientInterface. This is done either by HttpClient specifically implementing HttpClientInterface (class HttpClient implements HttpClientInterface
), or by HttpClient’s API simply being compatible with HttpClientInterface.
As soon as HttpClient modifies its API (for example, removes the get
method) and is thus no longer compatible with HttpClientInterface, the DI container throws an error ("the HttpClientInterface dependency was not provided").
Here the user, who wants to bring both implementations together, is in the obligation to find a solution. As an example, an adapter class could be registered here that implements HttpClientInterface and correctly forwards the method calls to HttpClient.
It should be noted here that although in theory the dependency inversion principle has its advantages, in practice it also has significant disadvantages. It not only leads to more code (since more interfaces have to be written), but also to more complexity (since each implementation now has an interface for each dependency). This price to pay is only worth it when the application reaches a certain size and this flexibility is needed. Like any design pattern and principle, this one has its cost-use factor, which should be thought through before it is applied. Design patterns should not be used blindly and across the board for even the simplest code. However, if the prerequisites such as a complex architecture, large applications, or a scaling team are given, dependency inversion and other design patterns only unfold their true strength.
Installation
Since Dependency Injection in Deepkit is based on Runtime Types, it is necessary to have @deepkit/type
already installed correctly. See Runtime Type Installation.
If this is done successfully, @deepkit/injector
can be installed by itself or the Deepkit framework which already uses the library under the hood.
npm install @deepkit/injector
Once the library is installed, the API of it can be used directly.
Use
To use Dependency Injection now, there are three ways.
-
Injector API (Low Level)
-
Modules API
-
App API (Deepkit Framework)
If @deepkit/injector
is to be used without the deepkit framework, the first two variants are recommended.
Injector API
The Injector API has already been introduced in the introduction to Dependency Injection. It is characterized by a very simple usage by means of a single class InjectorContext
which creates a single DI container and is particularly suitable for simpler applications without modules.
import { InjectorContext } from '@deepkit/injector';
const injector = InjectorContext.forProviders([
UserRepository,
HttpClient,
]);
const repository = injector.get(UserRepository);
The injector
object in this case is the dependency injection container. The function InjectorContext.forProviders
takes an array of providers. See the Dependency Injection Providers section to learn what values can be passed.
Modules API
A more complex API is the InjectorModule
class, which allows to store the providers in different modules to create multiple encapsulated DI containers per module. Also this allows using configuration classes per module, which makes it easier to provide configuration values automatically validated to the providers. Modules can import themselves among themselves, providers export, in order to build up so a hierarchy and nicely separated architecture.
This API should be used if the application is more complex and the Deepkit framework is not used.
import { InjectorModule, InjectorContext } from '@deepkit/injector';
const lowLevelModule = new InjectorModule([HttpClient])
.addExport(HttpClient);
const rootModule = new InjectorModule([UserRepository])
.addImport(lowLevelModule);
const injector = new InjectorContext(rootModule);
The injector
object in this case is the dependency injection container. Providers can be split into different modules and then imported again in different places using module imports. This creates a natural hierarchy that maps the hierarchy of the application or architecture.
The InjectorContext should always be given the top module in the hierarchy, also called root module or app module. The InjectorContext then only has an intermediary task: calls to injector.get()
are simply forwarded to the root module. However, it is also possible to get providers from non-root modules by passing the module as a second argument.
const repository = injector.get(UserRepository);
const httpClient = injector.get(HttpClient, lowLevelModule);
All non-root modules are encapsulated by default, so that all providers in this module are only available to itself. If a provider is to be available to other modules, this provider must be exported. By exporting, the provider moves to the parent module of the hierarchy and can be used that way.
To export all providers by default to the top level, the root module, the option forRoot
can be used. This allows all providers to be used by all other modules.
const lowLevelModule = new InjectorModule([HttpClient])
.forRoot(); //export all Providers to the root
App API
Once the Deepkit framework is used, modules are defined with the @deepkit/app
API. This is based on the Module API, so the capabilities from there are also available. In addition, it is possible to work with powerful hooks and to define configuration loaders in order to map even more dynamic architectures.
The Framework Modules chapter describes this in more detail.
Providers
There are several ways to provide dependencies in the Dependency Injection container. The simplest variant is simply the specification of a class. This is also known as short ClassProvider.
InjectorContext.forProviders([
UserRepository
]);
This represents a special provider, since only the class is specified. All other providers must be specified as object literals.
By default, all providers are marked as singletons, so only one instance exists at any given time. To create a new instance each time a provider is deployed, the transient
option can be used. This will cause classes to be recreated each time or factories to be executed each time.
InjectorContext.forProviders([
{provide: UserRepository, transient: true}
]);
ClassProvider
Besides the short ClassProvider there is also the regular ClassProvider, which is an object literal instead of a class.
InjectorContext.forProviders([
{provide: UserRepository, useClass: UserRepository}
]);
This is equivalent to these two:
InjectorContext.forProviders([
{provide: UserRepository}
]);
InjectorContext.forProviders([
UserRepository
]);
It can be used to exchange a provider with another class.
InjectorContext.forProviders([
{provide: UserRepository, useClass: OtherUserRepository}
]);
In this example, the OtherUserRepository
class is now also managed in the DI container and all its dependencies are resolved automatically.
ValueProvider
Static values can be provided with this provider.
InjectorContext.forProviders([
{provide: OtherUserRepository, useValue: new OtherUserRepository()},
]);
Since not only class instances can be provided as dependencies, any value can be specified as useValue
. A symbol or a primitive (string, number, boolean) could also be used as a provider token.
InjectorContext.forProviders([
{provide: 'domain', useValue: 'localhost'},
]);
Primitive provider tokens must be declared with the Inject type as a dependency.
import { Inject } from '@deepkit/injector';
class EmailService {
constructor(public domain: Inject<string, 'domain'>) {}
}
The combination of an inject alias and primitive provider tokens can also be used to provide dependencies from packages that do not contain runtime type information.
import { Inject } from '@deepkit/injector';
import { Stripe } from 'stripe';
export type StripeService = Inject<Stripe, '_stripe'>;
InjectorContext.forProviders([
{provide: '_stripe', useValue: new Stripe},
]);
And then declared on the user side as follows:
class PaymentService {
constructor(public stripe: StripeService) {}
}
ExistingProvider
A forwarding to an already defined provider can be defined.
InjectorContext.forProviders([
{provide: OtherUserRepository, useValue: new OtherUserRepository()},
{provide: UserRepository, useExisting: OtherUserRepository}
]);
FactoryProvider
A function can be used to provide a value for the provider. This function can also contain parameters, which in turn are provided by the DI container. Thus, other dependencies or configuration options are accessible.
InjectorContext.forProviders([
{provide: OtherUserRepository, useFactory: () => {
return new OtherUserRepository()
}},
]);
InjectorContext.forProviders([
{
provide: OtherUserRepository,
useFactory: (domain: RootConfiguration['domain']) => {
return new OtherUserRepository(domain);
}
},
]);
InjectorContext.forProviders([
Database,
{
provide: OtherUserRepository,
useFactory: (database: Database) => {
return new OtherUserRepository(database);
}
},
]);
InterfaceProvider
In addition to classes and primitives, abstractions (interfaces) can also be provided. This is done via the function provide
and is particularly useful if the value to be provided does not contain any type information.
import { provide } from '@deepkit/injector';
interface Connection {
write(data: Uint16Array): void;
}
class Server {
constructor (public connection: Connection) {}
}
class MyConnection {
write(data: Uint16Array): void {}
}
InjectorContext.forProviders([
Server,
provide<Connection>(MyConnection)
]);
Asynchronous Providers
Asynchronous providers are not possible due to the design, since an asynchronous Dependency Injection container would mean that requesting providers would also be asynchronous. would be and thus the entire application is already forced to asynchrony at the highest level.
To initialize something asynchronously, this initialization should be moved to the application server bootstrap, because there the events can be asynchronous. Alternatively, an initialization can be triggered manually.
TODO: Explain it better, maybe example
If multiple providers have implemented the Connection interface, the last provider is used.
As argument for provide() all other providers are possible.
const myConnection = {write: (data: any) => undefined};
InjectorContext.forProviders([
provide<Connection>({useValue: myConnection})
]);
InjectorContext.forProviders([
provide<Connection>({useFactory: () => myConnection})
]);
Constructor/Property Injection
In most cases, constructor injection is used. All dependencies are specified as constructor arguments and are automatically injected by the DI container.
class MyService {
constructor(protected database: Database) {
}
}
Optional dependencies should be marked as such, otherwise an error could be triggered if no provider can be found.
class MyService {
constructor(protected database?: Database) {
}
}
An alternative to constructor injection is property injection. This is usually used when the dependency is optional or the constructor is otherwise too full. The properties are automatically assigned once the instance is created (and thus the constructor is executed).
import { Inject } from '@deepkit/injector';
class MyService {
//required
protected database!: Inject<Database>;
//or optional
protected database?: Inject<Database>;
}
Configuration
The dependency injection container also allows configuration options to be injected. This configuration injection can be received via constructor injection or property injection.
The Module API supports the definition of a configuration definition, which is a regular class. By providing such a class with properties, each property acts as a configuration option. Because of the way classes can be defined in TypeScript, this allows defining a type and default values per property.
class RootConfiguration {
domain: string = 'localhost';
debug: boolean = false;
}
const rootModule = new InjectorModule([UserRepository])
.setConfigDefinition(RootConfiguration)
.addImport(lowLevelModule);
The configuration options domain
and debug
can now be used quite conveniently type-safe in providers.
class UserRepository {
constructor(private debug: RootConfiguration['debug']) {}
getUsers() {
if (this.debug) console.debug('fetching users ...');
}
}
The values of the options themselves can be set via configure()
.
rootModule.configure({debug: true});
Options that do not have a default value but are still necessary can be provided with a !
. This forces the user of the module to provide the value, otherwise an error will occur.
class RootConfiguration {
domain!: string;
}
Validation
Also, all serialization and validation types from the previous chapters Validation and Serialization can be used to specify in great detail what type and content restrictions an option must have.
class RootConfiguration {
domain!: string & MinLength<4>;
}
Injection
Configuration options, like other dependencies, can be safely and easily injected through the DI container as shown earlier. The simplest method is to reference a single option using the index access operator:
class WebsiteController {
constructor(private debug: RootConfiguration['debug']) {}
home() {
if (this.debug) console.debug('visit home page');
}
}
Configuration options can be referenced not only individually, but also as a group. The TypeScript utility type Partial
is used for this purpose:
class WebsiteController {
constructor(private options: Partial<RootConfiguration, 'debug' | 'domain'>) {}
home() {
if (this.options.debug) console.debug('visit home page');
}
}
To get all configuration options, the configuration class can also be referenced directly:
class WebsiteController {
constructor(private options: RootConfiguration) {}
home() {
if (this.options.debug) console.debug('visit home page');
}
}
However, it is recommended to reference only the configuration options that are actually used. This not only simplifies unit tests, but also makes it easier to see what is actually needed from the code.
Scopes
By default, all providers of the DI container are singletons and are therefore instantiated only once. This means that in the example of UserRepository there is always only one instance of UserRepository during the entire runtime. At no time is a second instance created, unless the user does this manually with the "new" keyword.
However, there are various use cases where a provider should only be instantiated for a short time or only during a specific event. Such an event could be, for example, an HTTP request or an RPC call. This would mean that a new instance is created for each event and after this instance is no longer used it is automatically removed (by the garbage collector).
An HTTP request is a classic example of a scope. For example, providers such as a session, a user object, or other request-related providers can be registered to this scope. To create a scope, simply choose an arbitrary scope name and then specify it with the providers.
import { InjectorContext } from '@deepkit/injector';
class UserSession {}
const injector = InjectorContext.forProviders([
{provide: UserSession, scope: 'http'}
]);
Once a scope is specified, this provider cannot be obtained directly from the DI container, so the following call will fail:
const session = injector.get(UserSession); //throws
Instead, a scoped DI container must be created. This would happen every time an HTTP request comes in:
const httpScope = injector.createChildScope('http');
Providers that are also registered in this scope can now be requested on this scoped DI container, as well as all providers that have not defined a scope.
const session = httpScope.get(UserSession); //works
Since all providers are singleton by default, each call to get(UserSession)
will always return the same instance per scoped container. If you create multiple scoped containers, multiple UserSessions will be created.
Scoped DI containers have the ability to set values dynamically from the outside. For example, in an HTTP scope, it is easy to set the HttpRequest and HttpResponse objects.
const injector = InjectorContext.forProviders([
{provide: HttpResponse, scope: 'http'},
{provide: HttpRequest, scope: 'http'},
]);
httpServer.on('request', (req, res) => {
const httpScope = injector.createChildScope('http');
httpScope.set(HttpRequest, req);
httpScope.set(HttpResponse, res);
});
Setup Calls
Setup calls allow to manipulate the result of a provider. This is useful for example to use another dependency injection variant, the method injection.
Setup calls can only be used with the module API or the app API and are registered above the module.
class UserRepository {
private db?: Database;
setDatabase(db: Database) {
this.db = db;
}
}
const rootModule = new InjectorModule([UserRepository])
.addImport(lowLevelModule);
rootModule.setupProvider(UserRepository).setDatabase(db);
The setupProvider
method thereby returns a proxy object of UserRepository on which its methods can be called. It should be noted that these method calls are merely placed in a queue and are not executed at this time. Accordingly, no return value is returned.
In addition to method calls, properties can also be set.
class UserRepository {
db?: Database;
}
const rootModule = new InjectorModule([UserRepository])
.addImport(lowLevelModule);
rootModule.setupProvider(UserRepository).db = db;
This assignment is also simply placed in a queue.
The calls or the assignments in the queue are then executed on the actual result of the provider as soon as this is created. That is with a ClassProvider these are applied to the class instance, as soon as the instance is created, with a FactoryProvider on the result of the Factory, and with a ValueProvider on the Provider.
To reference not only static values, but also other providers, the function injectorReference
can be used. This function returns a reference to a provider, which is also requested by the DI container when the setup calls are executed.
class Database {}
class UserRepository {
db?: Database;
}
const rootModule = new InjectorModule([UserRepository, Database])
rootModule.setupProvider(UserRepository).db = injectorReference(Database);
Abstractions/Interfaces
Setup calls can also be assigned to an interface.
rootModule.setupProvider<DatabaseInterface>().logging = logger;