A small dependency injection library providing inversion of control for asynchronous service construction.
Add github as npm registry within .npmrc:
@diba1013:registry=https://npm.pkg.github.comThen, add the dependency with the following command:
npm add @diba1013/di
While there are many inversion of control libraries out there, they usually come pre-packaged as part of a dedicated framework. As such, they use a customized own approach to solve the registration process of dependencies as well as how to retrieve / inject dependencies (e.g. decorators or module registration).
Standalone libraries often do not have this luxury of deeply integrated construction methods provided by frameworks. These often require a lot of boilerplate code, usually by the way of registering dependencies. These registration methods often only allow for synchronous registration or manual dependency declaration, eliminating the benefits of inversion of control.
This library tries to provide a simple and lightweight way of using inversion of control for asynchronous service construction. It does so by providing the following features:
- Asynchronous injectable factories to make use of expensive setup construction (e.g. code splitting or modules).
- Transparent dependency declaration by normal usage of invocation functions by abstracting injectable construction.
- Type-safety through inference so that types do not need to be explicitly defined for each factory.
A container represent a flat object consisting of key to injectable pairs. The injectable value can be any static object or a factory constructing the object based on a single parameter.
- Services are only constructed once per argument invocation. For now, arguments are serialized via
JSON.stringify. This may change at a later time. - The typical usage reserves a limited amount of root services that then construct the remaining dependency tree, eliminating to pass the scope through many files.
- An injectable can technically be of any return type, though plain objects / classes are assumed; functions are interpreted as parameterized factories for the resulting injectable.
A root type declares the resulting scope with plain objects converted to async factory functions.
type ServiceProvider = {
// A constant value initialized upon construction.
config: Config;
// Dynamically create services via a parameter.
cache: (namespace: string) => Cache;
// The root service that is invoked via the scope.
users: UserService;
}Constructing a container involves the usage of factory functions that are enriched by the use of a decorator service.
const services = inject<ServiceProvider>({
// A static factory can return virtually any object to be used later.
// This object can be transparently used with the `container` property from the factories' context.
// All factories must be annotated by the decorator to ensure proper type hints.
config: ({ decorator }) => {
// Although this is a plain object, it still needs to be wrapped within the factory to ensure proper initiation order.
return decorator.invoke(() => {
return {
cache: {
namespace: "application",
},
token: process.env.JWT_TOKEN,
};
});
},
// Using the container to access the static resolvable values (allowing for nested destructuring).
// This is done by invoking the function twice, therefore you should move all expensive code within the decorated factory:
// 1. Discover the accessed properties from the container within the immediate context.
// 2. Initializing the services asynchronously and finally invoking the factory.
cache: ({ decorator, container: { config: { cache } } }) => {
// A dynamic factory will have its arguments proxied to the factory, accepting multiple arguments if necessary..
return decorator.invoke((namespace) => {
return new Cache(`${cache.namespace)}:${namespace}`);
});
},
// Dynamic factories need to be invoked via the scope to allow to pass their arguments.
// While the scope can be accessed within the decorated factory the following principles should be adhered to:
// 1. Move all expensive calculation within the decorated factory.
// 2. Ensure that all required services have been accessed outside of the decorated factory via the container.
users: ({ decorator, container: { config }, scope: { cache } }) => {
// While the config has already been initialized here, the cache will only be initialized when the factory is called.
return decorator.invoke(async () => {
return new UserService({
cache: await cache("users"),
token: config.token,
});
});
},
});The resulting scope is used to instantiate the root services which then trigger the used injectables asynchronously.
// The invocation does not need to care about configuration or caching setup.
// 1. The config service is discovered through the use of the container.
// 2. The user factory function is called.
// 3. The cache service is triggered via the scope invocation (re-using the config singleton).
const users = await services.user();
// The service is returned as-is and can be used as if it were constructed manually.
const user = await users.fetch({
id: 123,
});To construct a dynamic service, you can invoke the injectable factory function and provide the required parameters, as declared on the passed ServiceProvider type.
// The invocation does not need to care about configuration or caching setup.
const cache = await services.cache("memory");
// The service is returned as-is and can be used as if it were constructed manually.
await cache.set({
id: 123,
});The following example describes the more preferred entry point for a more involved .
const services = create<ServiceProvider>({
// ... The same construction as above
});
// Resolve takes the same factory function as for a service, but does not bind itself to the invocation container.
// This allows for quick instantiation, especially when multiple asynchronous services are required.
// However, you still cannot pass the container directly as argument, because its fields must be destructured during outer factory invocation.
export const app = await services.resolve(({ decorator, container: { users } }) => {
return decorator.invoke(async () => {
return new App({
users,
});
})
});
await app.listen(3000);