🧁 InversifyJS framework to build hierarchical dependency systems with an elegant API.
Inversify Sugar is a set of decorators, types and functions built on top of Inversify and offers an API to handle TypeScript applications with multiple dependency containers and relationships between them.
Let me illustrate with a comparison.
Have you ever tried the Angular's dependency injection system?
import { NgModule } from "@angular/core";
import { CatsController } from "./cats.controller";
import { CatsService } from "./cats.service";
@NgModule({
declarations: [CatsController, CatsService],
})
export class CatsModule {}
import { NgModule } from "@angular/core";
import { CatsModule } from "./cats/cats.module";
@NgModule({
imports: [CatsModule],
})
export class AppModule {}
Or the NestJS one?
import { Module } from "@nestjs/common";
import { CatsController } from "./cats.controller";
import { CatsService } from "./cats.service";
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}
import { Module } from "@nestjs/common";
import { CatsModule } from "./cats/cats.module";
@Module({
imports: [CatsModule],
})
export class AppModule {}
Why can't we Inversify users organize our dependencies in such an elegant way?
This is how we have to write the same code in Inversify, with these 3 disadvantages:
- Your have to manage all the instantiated containers separately to scope the dependencies into modules (to build a hierarchical dependency system).
- Containers are initialized at the time the files that declare them are first imported.
- There is no single entry point to initialize all the containers.
import { Container } from "inversify";
import { CatsController } from "./CatsController";
import { CatsService } from "./CatsService";
const catsContainer = new Container();
catsContainer.bind(CatsController).toSelf().inSingletonScope();
catsContainer.bind(CatsService).toSelf().inSingletonScope();
export default catsContainer;
import { Container } from "inversify";
import "./cats/catsContainer";
const container = new Container();
container.bind("DATABASE_URI").toConstantValue(process.env.DATABASE_URI);
export container;
😵 The result is a brittle dependency system that we can break just by changing the order of the imported files. And we have to handle all the containers manually.
Inversify Sugar is a framework built on top of Inversify with a clear objective: to offer an API on par with the most cutting-edge hierarchical dependency systems.
Once you try it you will no longer be able to live without it.
Follow this small step-by-step guide to start using Inversify Sugar in your project.
Add the inversify-sugar
package to your project.
Using yarn:
yarn inversify-sugar
Or using npm:
npm install inversify-sugar
- The
inversify
package is already included withininversify-sugar
to expose only what is necessary. - Inversify Sugar installs and imports the
reflect-metadata
package under the hood, so we don't have to worry about adding any extra steps.
experimentalDecorators
, emitDecoratorMetadata
compilation options in your tsconfig.json
file.
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
All dependencies defined in the providers
field of this module are only visible to each other.
We can understand each module more or less as a compartmentalized container of Inversify. We will explain this later.
import { module } from "inversify-sugar";
import { CatsController } from "./CatsController";
import { CatsService } from "./CatsService";
@module({
providers: [CatsController, CatsService],
})
export class CatsModule {}
Define a root module, AppModule
, for your application and import the previously defined CatsModule.
import { module } from "inversify-sugar";
import { CatsModule } from "./cats/CatsModule";
@module({
imports: [CatsModule],
})
export class AppModule {}
Choose the newly defined AppModule
as the entry point of the dependency system.
import { InversifySugar } from "inversify-sugar";
import { AppModule } from "./AppModule";
// Configure the InversifySugar instance
InversifySugar.options.debug = process.env.NODE_ENV === "development";
InversifySugar.options.defaultScope = "Singleton";
// Entrypoint
InversifySugar.run(AppModule);
And that's it!
You can now start injecting your dependencies where you need them.
Let's not forget that Inversify Sugar works on top of Inversify, so to understand what's going on behind the scenes, we'll be referencing the original Inversify documentation throughout this guide.
Below you will find a detailed explanation of each of the concepts that this library handles together with different use examples and its public API.
A module is a class annotated with a @module()
decorator. The @module()
decorator provides metadata that is used to organize the dependency system.
Each application has at least one module, a root module. The root module is normally called AppModule
and is the starting point used to build the dependencies tree. While very small applications may theoretically have just the root module, for most applications, the resulting architecture will employ multiple modules, each encapsulating a closely related set of capabilities.
import { module } from "inversify-sugar";
import CatsModule from "./cats/CatsModule";
import DogsModule from "./dogs/DogsModule";
import BirdsModule from "./birds/BirdsModule";
@module({
imports: [CatsModule, DogsModule, BirdsModule],
providers: [],
exports: [],
})
export class AppModule {}
The relationship between modules would be as follows:
Once AppModule
is defined, we will only have to call the InversifySugar.run
method specifying the root module:
import { InversifySugar } from "inversify-sugar";
import { AppModule } from "./AppModule";
InversifySugar.run(AppModule);
The module decorator accepts an object argument with the imports
, providers
and exports
properties.
Next we will explain what each of these properties is for.
The list of imported modules that export the providers which are required in this module.
@module({
imports: [CatsModule],
})
export class AppModule {}
You can also use the forRoot
pattern to generate dynamic modules in the air and inject a configuration into the container.
The following example illustrates how we could inject a Mongoose database connection asynchronously from the options we pass as a parameter to the static forRoot
method.
@module({})
export default class MongooseModule {
static forRoot(config: MongooseConnectionConfig): DynamicModule {
const { uri, options } = config;
return {
module: MongooseModule,
providers: [
{
provide: MongooseConnectionToken,
useAsyncFactory: () => async () => {
if (!mongoose.connection || mongoose.connection.readyState === 0) {
await mongoose.connect(uri, options);
}
return mongoose.connection;
},
isGlobal: true,
},
{
provide: MongooseConnectionConfigToken,
useValue: config,
isGlobal: true,
},
],
};
}
}
Now we just need to import the dynamic module into the AppModule
to globally provide the database connection and configuration.
@module({
imports: [MongooseModule.forRoot({ uri: process.env.MONGO_URI })],
})
export class AppModule {}
The providers that will be instantiated when the module is registered. These providers may be shared at least across this module.
You can define a provider in different ways depending on the desired instantiation method.
@module({
providers: [
CatsService,
{
provide: CatsServiceToken,
useClass: CatsService,
},
{
provide: CatNameToken,
useValue: "Toulouse",
},
{
provide: CatNameFactoryToken,
useFactory:
(context) =>
(...args) =>
"New name",
},
],
})
export class CatsModule {}
You can also add the onActivation
and onDeactivation
handlers to providers that need it. Check the activation handler and deactivation handler sections of Inversify documentation for more information.
⚠️ Remember that theonDeactivation
handler will throw an error if we try to define it in a provider that does not have singleton scope.
The subset of providers that will be e available in other modules which import this module. You can use either a ExportedProvider
object or just its token (provide value).
If you export a provider with an injection token that is not registeres as a provider, an error will be thrown.
@module({
providers: [
CatsService,
{
provide: CatNameToken,
useValue: "Toulouse",
},
],
exports: [TestService, CatNameToken],
})
export class CatsModule {}
If more than one provider is registered for the same identifier, you will have to add the multiple
property to the ExportedProvider
.
@module({
providers: [
{
provide: CatNameToken,
useValue: "Toulouse",
},
{
provide: CatNameToken,
useValue: "Tomas O'Malley",
},
{
provide: CatNameToken,
useValue: "Duchess",
},
],
exports: [
{
provide: CatNameToken,
multiple: true,
},
],
})
export class CatsModule {}
@imported(CatNameToken) = ["Toulouse", "Tomas O'Malley", "Duchess"]
And if you want to re-export providers with an identifier that have been imported into a module you must add the deep
property.
@module({
providers: [
{
provide: CatNameToken,
useValue: "Toulouse",
},
{
provide: CatNameToken,
useValue: "Tomas O'Malley",
},
{
provide: CatNameToken,
useValue: "Duchess",
},
],
exports: [
{
provide: CatNameToken,
multiple: true,
},
],
})
export class CatsModule {}
@module({
imports: [CatsModule],
providers: [
{
provide: CatNameToken,
useValue: "Félix",
},
],
exports: [
{
provide: CatNameToken,
multiple: true,
deep: true,
},
],
})
export class MoreCatsModule {}
@imported(CatNameToken) = ["Toulouse", "Tomas O'Malley", "Duchess", "Félix"]
Ideally we shouldn't be accessing module containers directly to get a service. In either case, the getModuleContainer
function allows you to get the container of a module in case you need to access it in an statement.
import {
getModuleContainer,
module,
injectable,
InversifySugar,
} from "inversify-sugar";
@injectable()
class ProvidedService {}
@injectable()
class ExportedService {}
@module({
providers: [ProvidedService, ExportedService],
exports: [ExportedService],
})
class AModule {}
@module({
imports: [AModule],
})
class AppModule {}
InversifySugar.run(AppModule);
// Accessing the container of a module
const appModuleContainer = getModuleContainer(AppModule);
const testModuleContainer = getModuleContainer(TestModule);
// Getting a service provided to module
const providedService = testModuleContainer.getProvided(ProvidedService);
// Getting a provider imported to a module
const exportedService = appModuleContainer.getImported(ExportedService);
The container returned by the getModuleContainer()
function is a wrapper of the Inversify's Container
class that exposes only the necessary methods to access dependencies in both the providers section of the container and the container section of services imported by other modules.
It has been necessary for us to separate the providers declared in one module from those imported from another module in these compartments in order to implement the functionality of exporting imported suppliers (re-exporting).
isProvided(serviceIdentifier: interfaces.ServiceIdentifier<T>): boolean
isImported(serviceIdentifier: interfaces.ServiceIdentifier<T>): boolean
bindProvider(provider: Provider): void
bindExportedProviderRef(exportedProviderRef: ExportedProviderRef): void
getProvided<T = unknown>(serviceIdentifier: interfaces.ServiceIdentifier<T>): T
getAllProvided<T = unknown>(serviceIdentifier: interfaces.ServiceIdentifier<T>): T[]
getImported<T = unknown>(serviceIdentifier: interfaces.ServiceIdentifier<T>): T | T[]
getAllImported<T = unknown>(serviceIdentifier: interfaces.ServiceIdentifier<T>): T[]
unbindAll(): void
⚠️ For the moment thegetImported()
function will return a single value or an array depending on how many providers with the sameServiceIdentifier
have been imported into the module.So
getImported()
andgetAllImported()
will return the same list of services when more than one service with the same identifier is bound.However, we do not rule out that this API changes in the future.
When injecting the dependencies, either as a parameter in the constructor of a class, or as a property of the class, we have to use 2 sets of decorators that we have prepared.
You will have to use one or the other depending on how the dependency has been registered in the module.
⚠️ Splitting into different decorators for dependency injection adds extra complexity to the code, compared to Angular or NestJS injection systems. This is why the injection API may change in the future.In any case, this solution is not a whim, since to organize the content of the container of each module, the tagged bindings feature of Inversify is used.
We will use the @provided
decorator when we want to inject a provider into another provider that belongs to the same module (CatsModule
).
In the same way, we can use the @allProvided
decorator to obtain an array with all providers registered with that identifier. This would be the decorator equivalent to Inversify's @multiInject
.
// cats/CatsService.ts
import { injectable } from "inversify-sugar";
@injectable()
export class CatsService {}
// cats/constants.ts
export const CatNameToken = Symbol("CatName");
// cats/CatsController.ts
import { injectable, provided, allProvided } from "inversify-sugar";
import { CatsService } from "./CatsService";
import { CatNameToken } from './constants'
@injectable()
export class CatsController {
constructor(
@provided(CatsService) public readonly catsService: CatsService
@allProvided(CatNameToken) public readonly catNames: string[]
) {}
}
// cats/CatsModule.ts
import { module } from "inversify-sugar";
import { CatsController } from "./CatsController";
import { CatsService } from "./CatsService";
@module({
providers: [
CatsService,
CatsController,
{
provide: CatNameToken,
useValue: "Toulouse",
},
{
provide: CatNameToken,
useValue: "Tomas O'Malley",
},
{
provide: CatNameToken,
useValue: "Duchess",
},
],
})
export class CatsModule {}
We will use the @imported
decorator when we want to inject a provider exported by CatsModule
into a provider belonging to AppModule
which is importing CatsModule
.
// cats/CatsService.ts
import { injectable } from "inversify-sugar";
@injectable()
export class CatsService {}
// cats/CatsModule.ts
import { module } from "inversify-sugar";
import { CatsController } from "./CatsController";
import { CatsService } from "./CatsService";
@module({
providers: [CatsService],
exported: [CatsService],
})
export class CatsModule {}
// AppController.ts
import { injectable, imported } from "inversify-sugar";
import { CatsService } from "./cats/CatsService";
@injectable()
export class AppController {
constructor(
@imported(CatsService) public readonly catsService: CatsService
) {}
}
// AppModule.ts
import { module } from "inversify-sugar";
import { CatsModule } from "./cats/CatsModule";
@module({
imports: [CatsModule],
})
export class AppModule {}
⚠️ As you can see there is no@allImported()
decorator.As with the
ModuleContainer.getImported()
method, the@imported()
decorator will return a single value or an >array depending on how many providers with the specifiedServiceIdentifier
have been imported into the module.
The complexity of the memory state during the execution of Inversify Sugar, managing multiple Inversify containers under the hood, is too high to ensure that it is working correctly without writing unit tests of each of the functionalities separately.
For this reason, a set of tests have been written that you can consult here.
So you can use it without worries. You are facing a completely armored dependency system.
☕️ Buy me a coffee so the open source party never ends.
YouTube | Instagram | Twitter | Facebook
The Inversify Sugar source code is made available under the MIT license.
Some of the dependencies are licensed differently, with the BSD license, for example.