@alxcube/di-container
v1.0.1
Published
Simple but flexible type-safe dependency injection container for TypeScript applications.
Downloads
9
Maintainers
Readme
@alxcube/di-container
Simple but flexible type-safe dependency injection container for TypeScript applications.
Key Features
- Flexible Service Creation: Utilize factories to create services with injected dependencies and customizable configurations, enhancing flexibility in service instantiation.
- Interface-Implementation Binding: Seamlessly link interfaces with their respective implementations.
- Lifecycle Management: Control the lifecycle of services with support for transient, singleton, and request scopes.
- Circular Dependency Support: Handle circular dependencies gracefully.
- Contextual Resolution: Dynamically resolve dependencies based on the context, enhancing adaptability.
- Type Safety: Ensure type safety throughout the dependency injection process, enhancing code robustness and reliability.
- Error Handling: Detect and manage errors during service resolution with clear and informative error messages, facilitating debugging.
- Testing Support: Simplify unit testing by instantiating classes with injected dependencies using the container's features. Additionally, backup and restore container state for seamless testing.
Installation
npm i @alxcube/di-container
Glossary
This section explains the terms used in this documentation.
Service Container
Implementation of Dependency Injection Container pattern.
Service Resolution Context
Service resolution context is a special object available in service factories. This object provides methods for retrieving services that are dependencies of the service being created by the factory. It exists within the scope of a single root service request. Additionally, it provides methods to determine whether the current service is being resolved as a dependency for another service, as well as the current stack of service resolution.
See ServiceResolutionContext interface.
Service
Service - an object or value obtained from the container. In general, services are implementations of your application's interfaces, but the container allows storing values of any type.
Service Factory
Service factory is a callback function that will be invoked when a service is requested from the container. The sole argument of the function is the Service Resolution Context object, which allows obtaining dependencies of the constructed service. This function should return a value of the corresponding type.
Service Map
Service map is an auxiliary interface that represents the mapping between service keys and service types. It enables TypeScript to leverage type inference, making calls to container methods type-safe, and assists you in working with code hints from your IDE.
While any strings can be used as property names in this interface, it is recommended to name them according to the names of your application's interfaces.
Index signature ([key: string]: any;
) should not be used, since it breaks type inference.
interface ServicesMap {}
Service Key
Service key is either a key of the service map interface or a class constructor. Services are registered and retrieved from the container using this key.
type ServiceKey<TServicesMap extends ServicesMap> =
| keyof TServicesMap
| Constructor<object>
Named Service Key
Object with two properties: service
- the service key, and name
-
the service name.
interface NamedServiceKey<TServicesMap extends ServicesMap> {
service: ServiceKey<TServicesMap>;
name: string;
}
Service Token
Type alias for union of service key and named service key.
type ServiceToken<TServicesMap extends ServicesMap> =
| ServiceKey<TServicesMap>
| NamedServiceKey<TServicesMap>;
Constant Token
Constant token is an object with a single property 'constant'
, which can hold a value
of any type. It is used to declare dependencies of a class that are not retrieved from
the container and are passed directly to the class constructor in methods
registerClassConfig()
, implement()
and instantiate()
.
Dependencies Tuple
Dependencies tuple is a special type of tuple whose members are
service tokens, or constant tokens, from which
values of the corresponding type are resolved. It is used for declaratively specifying the
dependencies of a class constructor in the methods registerClassConfig()
, implement()
,
and instantiate()
.
Usage
Create Service Map
First of all, you need to create a service map and specify in it the types of services that will be available in the container.
// TypesMap.ts
import type { ServicesMap } from "@alxcube/di-container";
import type { HttpClient } from "./HttpClient";
import type { BackendApiClient } from "./BackendApiClient";
// Extending of ServicesMap is not required, but recommended for clarity.
export interface TypesMap extends ServicesMap {
HttpClient: HttpClient;
"HttpClient[]": HttpClient[];
BackendApiClient: BackendApiClient;
applicationKey: string;
}
Creating Container
After declaring service map, you are now ready to create container instance:
// container.ts
import { Container } from "@alxcube/di-container";
import type { TypesMap } from "./TypesMap";
export const container = new Container<TypesMap>();
Resolving Services
There are several methods for resolving services from container.
Resolving Single Service
To retrieve a service from the container, you use the resolve()
method. It takes the
service key as an argument and returns a value of the corresponding type.
If the key passed is key of service map, the method returns the
corresponding type according to the map. If a constructor is passed as the key, an instance
of the provided class will be returned. However, remember to register the appropriate
service factory for the class; the container does not inherently know
how to instantiate class instances.
The second argument of the method, name
, allows you to retrieve a service with the
corresponding name. If this parameter is omitted, the name "default" is implicitly used
(the same applies to registration in the container).
If there is no service registered in the container with the given key or name, a
RangeError
will be thrown.
const httpClient: HttpClient = container.resolve("HttpClient");
// There is no need to declare types, since type inference works using types map
const paymentsApiHttpClient = container.resolve("HttpClient", "payment");
Resolving Array Of Services
The resolveAll() method takes a service key and returns an array of services of the corresponding type registered under different names. If there are no registrations for this service in the container, an empty array will be returned.
// The inferred type is HttpClient[]
const httpClients = container.resolveAll("HttpClient");
Resolving Tuple Of Services
To obtain a tuple of services, you use the resolveTuple()
method. It takes a tuple as
an argument, whose members are service tokens. The return value is a
tuple of the corresponding services.
This method can be useful when you need to retrieve multiple services from the container
within the same context, when the services have a 'request'
lifecycle. Using this method for
services with other lifecycles does not make sense. The method is also available on
the service resolution context object (in
service factories), but using it there does not make sense either, as
calls to resolve()
on the ServiceResolutionContext
object will already return services
within the context of the same request.
const [httpClient, backendApiClient] = container.resolveTuple([
{
service: "HttpClient",
name: "backend"
}, // Named services can be resolved, using NamedServiceKey interface
"BackendApiClient"
] as const); // Don't forget "as const" to make type inference work
Retrieving Service Names
Using the getServiceNames()
method, you can obtain an array of all names under which a
service with the given service key has been registered. If the service
was registered without explicitly specifying a name, this array will include the name
"default"
. If the service was not registered, an empty array will be returned.
container.registerConstant("HttpClient", new ConcreteHttpClient());
container.registerConstant("HttpClient", new AnotherHttpClient(), { name: "another" });
console.log(container.getServiceNames("HttpClient")); // ["default", "another"]
Registering Services
There are several ways to register services.
Registering Constant
To register a constant value, you use the registerConstant()
method. It takes a
service key and the constant value as arguments. Values of any type are
supported, except for undefined
. For example, you can register a primitive value or a
singleton object created outside the container using this method.
The third optional argument is an options object. The following options are available:
name
- Allows registering multiple services of the same type under the same service key. It serves to differentiate between services of the same type. If this option is not specified, the name"default"
will be used implicitly.replace
- When set totrue
, it replaces the service (taking into account the service name) that was previously registered. If the option is set tofalse
or omitted, and a service with the given key (and name) is already registered, aTypeError
will be thrown.
// Register string constant
container.registerConstant("applicationKey", "my_app_key");
// Register interface implementation as singleton
container.registerConstant("HttpClient", new ConcreteHttpClient(), { name: "payments" });
// Replace registered services
container.registerConstant("applicationKey", "other_app_key", { replace: true });
container.registerConstant("HttpClient", new ConcreteHttpClient(), { name: "payments", replace: true });
Registering Service Factory
Service factories are the most flexible and versatile way to register a service. To register
a factory, you use the registerFactory()
method. It takes three parameters: the
service key as the first parameter, the factory function
as the second parameter, and an options object as the third parameter.
This factory function accepts the context object as an
argument and should return the corresponding service. Dependencies of the constructed
service can be obtained from the context object using the resolve()
, resolveAll()
, or
resolveTuple()
methods.
This factory function will be invoked when the service is requested from the container or as a dependency of another service in another factory function.
The lifecycle of the created service is regulated by the lifecycle
option, which can
have one of three values:
"transient"
(default) - for each request of this service, the factory function will be called, generally returning a new instance of the service."singleton"
- the factory function will be called once, after which the created instance of the service will be stored in the container. For all subsequent requests, the same instance of the service will be returned throughout the application's lifetime (assuming the service registration is not updated)."request"
- operates similarly to"singleton"
, but only within the scope of a single root request. A root request is considered to be a call to one of theresolve()
,resolveAll()
, orresolveTuple()
methods on the container instance. Thus, when resolving a service of one root request, all services that depend on the service with the"request"
lifecycle will receive the same instance of it, but in the next root request, this instance will be different.
In addition to lifecycle
, the options of the registerFactory()
method also include
name
and replace
, the meaning and action of which are identical to the similarly named
options of the registerConstant()
method.
// Register interface implementation as singleton
container.registerFactory(
"HttpClient",
() => new ConcreteHttpClient(),
{ lifecycle: "singleton" }
);
// Register interface implementation with dependencies
container.registerFactory(
"BackendApiClient",
(context) => new ConcreteBackendClient(context.resolve("HttpClient"))
);
// Register service factory, using constructor as key
container.registerFactory(TextEncoder, () => new TextEncoder());
Registering Class Configuration
In general, when only dependency injections through a class constructor are used, your class factories may look quite similar:
container.registerFactory(
MyClass,
(context) => new MyClass(context.resolve("Dep1"), context.resolve("Dep2"))
)
To free you from routine and make class factory registration more declarative, the
registerClassConfig()
method is designed. It takes the class constructor as the first
argument, and as the second argument, it accepts a
tuple of dependencies, the corresponding members of which will be
used to extract the constructor dependencies in the respective order.
The third argument is an options object. In addition to options of
registerFactory()
method, there are one more option:
circular
- this should be set to true, when class has circular dependencies. See details below in corresponding section.
Please note that you can pass dependencies that are not directly extracted from the container by using a constant token. Typically, this applies to primitive data types that do not make much sense to store in the container.
You can use the constant()
helper for convenience in creating constant tokens.
import { constant } from "@alxcube/di-container";
// Register different configurations with constant token
container.registerClassConfig(
TextDecoder,
[{ constant: "utf-8" }],
{ name: "utf8" }
);
container.registerClassConfig(
TextDecoder,
[constant("koi8-r")], // use `constant()` helper
{ name: "koi8" }
);
// Resolving
const utf8Decoder = container.resolve(TextDecoder, "utf8");
const koi8Decoder = container.resolve(TextDecoder, "koi8");
// Registering class config with container dependencies
container.registerClassConfig(
PaymentsApiClient,
[
{ service: "HttpClient", name: "payment" }, // Dependency on service with specific name
"XmlParser", // Dependency on default service
TextDecoder, // Dependency on class
]
);
You also can use classNames
Map for binding constructors to their names. This helps
to keep meaningful class names in error messages after your code gets minified.
import { classNames } from "@alxcube/di-container";
classNames.set(ConcreteHttpClient, "ConcreteHttpClient");
Registering Interface Implementation
Similarly, the implement()
method works like the registerClassConfig()
method,
allowing you to declaratively bind an interface to its implementing class. The first
argument of the method takes the string name of the interface, which is key of the
service map. The second argument is the constructor of the class
implementing this interface. The third argument is a
tuple of class dependencies, just like in
registerClassConfig()
.
The fourth argument is options, which are the same as in
registerClassConfig()
.
// Register implementaion with no dependencies
container.implement("HttpClient", ConcreteHttpClient, []);
// Register implementation with dependencies
container.implement("BackendApiClient", ConcreteBackendClient, ["HttpClient"]);
Generating Array Resolvers
Some of your classes may depend on an array of homogeneous interfaces from the container.
Typically, such a dependency is resolved using the resolveAll()
method inside the
service factory:
container.registerFactory(
"HttpClientsPool",
(context) => new ConcreteHttpClientsPool(
context.resolveAll("HttpClient")
)
);
To be able to leverage the benefits of declarative dependency specification in methods like
registerClassConfig()
or implement()
, you can use the createArrayResolver()
method.
First, add a separate type for the array of the selected service to your service map. For example, it might look like this:
interface AppServiceMap {
HttpClient: HttpClient;
"HttpClient[]": HttpClient[];
}
Then pass the keys of the single type and the corresponding array type to the
createArrayResolver()
method:
container.createArrayResolver("HttpClient", "HttpClient[]");
This will be equivalent to the following code:
container.registerFactory("HttpClient[]", (context) => context.resolveAll("HttpClient"));
Now you can declaratively use the dependency on the array:
container.implement("HttpClientPool", ConcreteHttpClientsPool, ["HttpClient[]"]);
The createArrayResolver()
method also accepts options similar to the options of the
registerFactory()
method.
Child Containers
The createChild()
method creates an empty child container. It is "empty" in the sense
that it initially does not have its own service registrations, but all services registered
in the parent container are also accessible in the child container.
When registering services in the child container, they override registrations with the same name from the parent container.
When removing a service from the child container, the existing registration from the
parent container (if it exists) is not removed unless the cascade
parameter of the
unregister()
method is set to true
.
To obtain the parent container, you can use the getParent()
method. It returns the
parent container or undefined
if the container has no parent.
It is important to note that when requesting a service from the child container, a process of merging all registrations across the container hierarchy occurs, which may lead to performance degradation when there are a large number of registrations.
// Register interface implementation as singleton
container.implement("HttpClient", ConcreteHttpClient, [], { lifecycle: "singleton" });
// Create child container
const childContainer = container.createChild();
// Resolve http client from child container
const httpClient1 = childContainer.resolve("HttpClient");
// Override interface implementation in child container. (No need to pass `true` as
// `replace` option value, since child container hasn't own registration of HttpClient
childContainer.implement(
"HttpClient",
AxiosHttpClient,
["AxiosInstanceFactory"],
{ lifecycle: "singleton" }
);
// Resolve new implementation of http client from child container
const httpClient2 = childContainer.resolve("HttpClient");
// Now there are different implementations in parent and child containers.
console.log(httpClient1 === httpClient2); // false
// Parent container keeps old registration
console.log(httpClient1 === container.resolve("HttpClient")); // true
Checking Service Existence
The has()
method takes a service key and an optional service name as
arguments and returns true
if such a service is registered in the container, and false
otherwise. If no name is provided, the method returns true
if there is at least one
registration for the service with that key. If a name is provided, it checks for the
existence of a registration with that specific name.
The hasOwn()
method works similarly, with the only difference being that, unlike the
has()
method, which checks for registrations in parent containers as well, the hasOwn()
method only checks for service registration in the container on which it was called.
// assume that HttpClient is registered in parent container, and not registered in child
console.log(childContainer.has("HttpClient")); // true
console.log(childContainer.hasOwn("HttpClient")); // false
console.log(container.has("HttpClient")); // true
console.log(container.hasOwn("HttpClient")); // true
// Check with name
console.log(container.has("HttpClient", "default")); // true
console.log(container.has("HttpClient", "not-registered-name")); // false
Unregistering Service
To remove a service registration, the unregister()
method is used. The only mandatory
argument is the service key. The second argument is the service name.
The third argument, cascade
, indicates whether the service should also be removed from
all parent containers.
If the service name is not specified (or is undefined
), all service registrations with
that key will be removed. If a name is provided, only the registration with the
corresponding name will be removed.
// Unregister HttpClient from child container.
childContainer.unregister("HttpClient");
// Unregistering services that are not registered does nothing
childContainer.unregister("HttpClient");
childContainer.unregister("HttpClient", "default");
// Unregister service from whole container hierarchy
console.log(parentContainer.has("HttpClient")); // true
childContainer.unregister("HttpClient", undefined, true);
console.log(parentContainer.has("HttpClient")); // false
Circular dependencies
If your classes have circular dependencies, and for some reason you cannot refactor to eliminate them, there are 2 ways to register classes with circular dependencies.
Using circular()
Helper
The first way is to wrap your service factory using the circular()
helper:
import { circular } from "@alxcube/di-container";
class CircularA {
constructor(private readonly circularB: CircularB) {}
}
class CircularB {
constructor(private readonly circularA: CircularA) {}
}
container.registerFactory(
CircularA,
circular(
(context) => new CircularA(
context.resolve(CircularB)
)
)
);
container.registerFactory(
CircularB,
circular(
(context) => new CircularB(
context.resolve(CircularA)
)
)
);
This function will return a service factory that creates a JavaScript Proxy, replacing the requested class, and only when this object is first accessed, your factory will be called, which will create an instance of the class.
If you register classes with circular dependencies using the registerClassConfig()
or
implement()
methods, set the circular
option to true
. Under the hood, this will
wrap the generated service factory using the circular()
helper.
Using Delayed Dependencies Injection
The second approach is delayed dependency injection. This method is less convenient and not as universal, but it does not use proxies, so it may be useful if proxies are not available in your application environment, although this is unlikely.
To implement this approach, your class dependencies must be injected through public
properties or must have setter methods. Additionally, the "singleton"
or "request"
lifecycle is a mandatory requirement for such circular dependencies.
You can pass a callback to the delay()
method of the context object
in the service factory, in which you can resolve and set the necessary
dependencies. This callback will be invoked after resolving the dependency stack of the
current service.
class CircularA {
constructor(public circularB: CircularB = undefined) {}
}
class CircularB {
constructor(public circularA: CircularA = undefined) {}
}
container.registerFactory(
CircularA,
(context) => {
// Create instance without dependency
const instance = new CircularA();
// Delay resolution and injection of dependency
context.delay(() => instance.circularB = context.resolve(CircularB));
// Return instance
return instance;
},
{
// Lifecycle must be either "request" or "singleton"
lifecycle: "request"
}
);
// Same for other circular dependency
container.registerFactory(
CircularB,
(context) => {
const instance = new CircularB();
context.delay(() => instance.circularA = context.resolve(CircularA));
return instance;
},
{ lifecycle: "request" }
);
Service Modules
To categorize registrations in the container and separate code, you can use service modules.
These are simple objects with a single register()
method, which takes the container
object as its sole parameter.
To activate a module, pass it to the loadModule()
method of the container.
// module.ts
import type { ServiceModule } from "@alcube/di-container";
import type { AppServiceMap } from "./AppServiceMap";
export const module: ServiceModule<AppServiceMap> = {
register(container) {
container.registerConstant("applicationKey", "some-app-key");
}
}
// container.ts
import { Container } from "@alxcube/di-container";
import type { AppServiceMap } from "./AppServiceMap";
import { module } from "./module";
const container = new Container<AppServiceMap>();
container.loadModule(module);
Testing
The container also provides some methods that are useful for testing purposes.
Container Snapshots
Using the backup()
method, you can create snapshots of the container's state, and with
the restore()
method, you can roll back the container's state to a previous snapshot.
Snapshots work on a stack principle, and their number is unlimited. Typically, you would
use the backup()
and restore()
methods, respectively, in the beforeEach()
and
afterEach()
hooks of your testing framework.
Calling the backup()
method without parameters creates a snapshot of the container on
which it was called. However, if the optional parameter cascade
is set to true
, this
method will also be called on all parent containers, causing them to create snapshots of
their own state. The restore()
method works similarly. Be careful when using cascading
snapshots and remember to set the cascade
parameter to true
for the corresponding
restore()
calls, otherwise, you may encounter hard-to-track container state violations.
let httpClientSpy: HttpClientSpy;
beforeEach(() => {
container.backup();
httpClientSpy = new HttpClientSpy();
container.registerConstant("HttpClient", httpClientSpy, { replace: true });
});
afterEach(() => {
container.restore();
})
Creating Class Instances
The instantiate()
method exists for conveniently creating instances of a class with
dependency injection through the constructor, using the container. This method takes the
class constructor as the first argument and a
tuple of dependencies as the second argument, and returns an
instance of the provided class. This is convenient for use in unit testing specific
classes.
let backendClient: ConcreteBackendClient;
beforeEach(() => {
backendClient = container.instantiate(ConcreteBackendClient, ["HttpClient"]);
})
Contextual Dependencies Resolving
To contextually resolve dependencies, you can use the methods isResolvingFor()
and
isDirectlyResolvingFor()
of the context object inside
service factories. Both methods take a service key and
an optional service name as parameters.
The first method returns true
if the current
service (returned by the factory) is resolved as a dependency at any level for the
corresponding service. This means, for example, that the current service can be a
dependency of a dependency of the service whose key is passed to the method.
The second method is similar to the first one but checks if the current service is a direct dependency of the corresponding service.
If the name
argument is not provided, only the service key is considered, and the
name is ignored. To check if the current service is resolved specifically for the
default registration of another service, pass "default"
as the second argument
explicitly.
You can also get the entire current dependency resolution stack by calling the getStack()
method of the context object. The stack is an array of
named service keys, where the first element is the service key
requested from the container, and the last element is the key of the current service
(in whose factory the check is performed).
Note that for this factory to work correctly, it must have a 'transient'
lifecycle.
// Register class configs for implementations (this may be singletons)
container.registerClassConfig(LocalFileSystemDriver, [], { lifecycle: "singleton" });
container.registerClassConfig(CloudFileSystemDriver, ["HttpClient"]);
// Register contextual service factory (this must be transient)
container.registerFactory("FileSystem", (context) => {
if (context.isResolvingFor("UserpicRepository")) {
return context.resolve(LocalFileSystemDriver);
}
return context.resolve(CloudFileSystemDriver);
});
Service Resolution Error
When an error occurs during the resolution of a service, a ServiceResolutionError
will
be thrown.
This error class contains a stack
property representing the service resolution stack.
The stack is an array of named service keys, where the first element
is the service key requested from the container, and the last element is the key of the
service in whose factory the error occurred.
The cause
property of the ServiceResolutionError
object contains the caught value that
caused the failure.
The message
property contains a string that includes the string representation of the
caught value, as well as the textual representation of the resolution stack in reverse
order: the first line of the stack represents the key and name of the service in whose
factory the error occurred.