xInjection is a robust Inversion of Control (IoC) library that extends InversifyJS with a modular, NestJS-inspired Dependency Injection (DI) system. It enables you to encapsulate dependencies with fine-grained control using ProviderModule classes, allowing for clean separation of concerns and scalable architecture.
Each ProviderModule
manages its own container, supporting easy decoupling and explicit control over which providers are exported and imported across modules. The global AppModule is always available, ensuring a seamless foundation for your application's DI needs.
middlewares
.modules
without eagerly instantiating them.First, ensure you have reflect-metadata
installed:
npm i reflect-metadata
You may have to add import 'reflect-metadata'
at the entry point of your application.
Then install xInjection
:
npm i @adimm/x-injection
Add the following options to your tsconfig.json
to enable decorator metadata:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
import { Injectable, ProviderModule } from '@adimm/x-injection';
@Injectable()
class HelloService {
sayHello() {
return 'Hello, world!';
}
}
const HelloModule = ProviderModule.create({
id: 'HelloModule',
providers: [HelloService],
exports: [HelloService],
});
const helloService = HelloModule.get(HelloService);
console.log(helloService.sayHello());
// => 'Hello, world!'
The core class of xInjection
, if you ever worked with NestJS (or Angular), you'll find it very familiar.
const GarageModule = ProviderModule.create({ id: 'GarageModule', imports: [], providers: [], exports: [] });
It is a special instance of the ProviderModule
class which acts as the root
of your modules
graph, all global modules will be automatically imported into the AppModule
and shared across all your modules.
Another core class which most probably you'll end using a lot too, to keep it short, it allows you to plan ahead the modules
without instantiating them.
const CarModuleBlueprint = ProviderModule.blueprint({ id: 'CarModule', imports: [], providers: [], exports: [] });
It is used to refer to the three main blocks of a module:
The library has some opinionated naming conventions which you should adopt too
All variables holding an instance of a ProviderModule
should be written in PascalCase and suffixed with Module
, like this:
const DatabaseModule = ProviderModule.create({...});
const UserModule = ProviderModule.create({...});
const CarPartsModule = ProviderModule.create({...});
The id
property of the ProviderModule.options
should be the same as the module
variable name.
const DatabaseModule = ProviderModule.create({ id: 'DatabaseModule' });
const UserModule = ProviderModule.create({ id: 'UserModule' });
const CarPartsModule = ProviderModule.create({ id: 'CarPartsModule' });
If you are exporting a module
from a designated file, then you should name that file as following:
database.module.ts
user.module.ts
car-parts.module.ts
If you install/use the Material Icon Theme VS Code extension, you'll see the *.module.ts
files with a specific icon.
All variables holding an instance of a ProviderModuleBlueprint
should be written in PascalCase too and suffixed with ModuleBp
, like this:
const DatabaseModuleBp = ProviderModule.blueprint({...});
const UserModuleBp = ProviderModule.blueprint({...});
const CarPartsModuleBp = ProviderModule.blueprint({...});
The id
property of the ProviderModuleBlueprint.options
should not end with Bp
because when you'll import that blueprint
into a module
, the exact provided id
will be used!
const DatabaseModuleBp = ProviderModule.create({ id: 'DatabaseModule' });
const UserModuleBp = ProviderModule.create({ id: 'UserModule' });
const CarPartsModuleBp = ProviderModule.create({ id: 'CarPartsModule' });
If you are exporting a blueprint
from a designated file, then you should name that file as following:
database.module.bp.ts
user.module.bp.ts
car-parts.module.bp.ts
All variables holding an object representing a ProviderToken should be written in SCREAMING_SNAKE_CASE and suffixed with _PROVIDER
, like this:
const USER_SERVICE_PROVIDER = UserService;
If you are exporting a provider token
from a designated file, then you should name that file as following:
user-service.provider.ts
As explained above, it is the root
module of your application, it is always available and eagerly bootstrapped.
Usually you'll not interact much with it as any module
which is defined as global will be automatically imported into it, therefore having its exports
definition available across all your modules out-of-the-box. However, you can use it like any ProviderModule
instance.
Importing the AppModule
into any module
will throw an error!
You have 2 options to access it:
import { AppModule } from '@adimm/x-injection';
or
import { ProviderModule } from '@adimm/x-injection';
ProviderModule.APP_MODULE_REF;
// This option is mostly used internally, but you can 100% safely use it as well.
Providing global services to the AppModule
:
@Injectable()
class UserService {}
AppModule.update.addProvider(UserService);
All providers
scope is set to Singleton by default if not provided.
Yes, that's it, now you have access to the UserService
anywhere in your app across your modules
, meaning that you can now do:
const UnrelatedModule = ProviderModule.create({ id: 'UnrelatedModule' });
const userService = UnrelatedModule.get(UserService);
// returns the `userService` singleton instance.
You can see all the available properties
and methods
of the ProviderModule
here.
There are mainly 3 first-class ways to set the InjectionScope of a provider
, and each one has an order priority.
The below list shows them in order of priority (highest to lowest), meaning that if 2 (or more) ways are used, the method with the highest priority will take precedence.
const USER_PROVIDER: ProviderToken<UserService> = {
scope: InjectionScope.Request,
provide: UserService,
useClass: UserService,
};
@Injectable(InjectionScope.Transient)
class Transaction {}
ProviderModule
:const RainModuleDef = new ProviderModuleDef({
id: 'RainModule',
defaultScope: InjectionScope.Transient,
});
Imported modules/providers retain their original InjectionScope
!
The Singleton injection scope means that once a dependency has been resolved from within a module will be cached and further resolutions will use the value from the cache.
Example:
expect(MyModule.get(MyProvider)).toBe(MyModule.get(MyProvider));
// true
The Transient injection scope means that a new instance of the dependency will be used whenever a resolution occurs.
Example:
expect(MyModule.get(MyProvider)).toBe(MyModule.get(MyProvider));
// false
The Request injection scope means that the same instance will be used when a resolution happens in the same request scope.
Example:
@Injectable(InjectionScope.Transient)
class Book {
author: string;
}
@Injectable(InjectionScope.Request)
class Metro2033 extends Book {
override author = 'Dmitry Alekseyevich Glukhovsky';
}
@Injectable(InjectionScope.Transient)
class Library {
constructor(
public readonly metro2033: Metro2033,
public readonly metro2033_reference: Metro2033
) {}
}
const winstonLibrary = MyModule.get(Library);
const londonLibrary = MyModule.get(Library);
expect(winstonLibrary.metro2033).toBe(winstonLibrary.metro2033_reference);
expect(londonLibrary.metro2033).toBe(londonLibrary.metro2033_reference);
// true
expect(winstonLibrary.metro2033).toBe(londonLibrary.metro2033);
// false
A ProviderToken is another core block of xInjection
(and also many other IoC/DI libs) which is used to define a token
which can then be used to resolve a provider
.
xInjection
offers 4 types of tokens:
value
to a specific transparent token, like a Class
, Function
, symbol
or string
:const API_SERVICE_PROVIDER = ApiService;
// or
const CONSTANT_SECRET_PROVIDER = 'Shh';
const HUMAN_SERVICE_PROVIDER = { provide: HumanService, useClass: FemaleService };
// This will bind `HumanService` as the `token` and will resolve `FemaleService` from the container.
const THEY_DONT_KNOW_PROVIDER = { provide: CONSTANT_SECRET_PROVIDER, useValue: `They'll never know` };
const THEY_MAY_KNOW_PROVIDER = { provide: CONSTANT_SECRET_PROVIDER, useValue: 'Maybe they know?' };
// As you can see we now have 2 different ProviderTokens which use the same `provide` key.
// This means that resolving the `CONSTANT_SECRET_PROVIDER` will return an array of strings.
factory
which is intended for more complex scenarios:const MAKE_PIZZA_PROVIDER = {
provide: 'MAKE_PIZZA',
useFactory: async (apiService: ApiService, pizzaService: PizzaService) => {
const typeOfPizza = await apiService.getTypeOfPizza();
if (typeOfPizza === 'margherita') return pizzaService.make.margherita;
if (typeOfPizza === 'quattro_stagioni') return pizzaService.make.quattroStagioni;
// and so on
},
// optional
inject: [API_SERVICE_PROVIDER, PizzaService],
};
These are all the available ProviderToken
you can use.
In NestJS
and Angular
you can't use a ProviderToken
to get a value, xInjection
allows this pattern, but you must understand that what it actually does, is to use the value from the provide
property.
As you already saw till here, everything relies around the ProviderModule
class, so let's dive a little more deep into understanding it.
The most straight forward way to create/instantiate a new module
is:
const MyModule = ProviderModule.create({
id: 'MyModule',
imports: [AnotherModule, SecondModule, ThirdModule],
providers: [
{ provide: CONSTANT_SECRET_PROVIDER, useValue: 'ultra secret' },
PizzaService,
{ provide: HumanService, useClass: FemaleService },
],
exports: [SecondModule, ThirdModule, PizzaService],
});
From what we can see, the MyModule
is importing into it 3 more modules, each of them may export one or more (maybe nothing, that's valid too) providers, or even other modules
.
Because we imported them into the MyModule
, now we have access to any providers they may have chosen to export, and the same is true also for their exported modules.
Then, we've chosen to re-export from the MyModule
the SecondModule
and ThirdModule
, meaning that if a different module
imports MyModule
, it'll automatically get access to those 2 modules as well. And in the end we also exported our own PizzaService
, while the remaining other 2 providers, CONSTANT_SECRET_PROVIDER
and HumanService
can't be accessed when importing MyModule
.
This is the core feature of xInjection
(and Angular
/NestJS
DI system), being able to encapsulate the providers, so nothing can spill out without our explicit consent.
We could also achieve the above by using the ProviderModule
API like this:
MyModule.update.addImport(AnotherModule);
MyModule.update.addImport(SecondModule, true); // `true` means "also add to the `exports` definition"
MyModule.update.addImport(ThirdModule, true);
MyModule.update.addProvider({ provide: CONSTANT_SECRET_PROVIDER, useValue: 'ultra secret' });
MyModule.update.addProvider(PizzaService, true);
MyModule.update.addProvider({ provide: HumanService, useClass: FemaleService });
Now you may probably ask yourself If we import with the 'addImport' method a new module into an already imported module, will we have access to the providers of that newly imported module?
The ansuwer is yes
, we do have access thanks to the dynamic nature of the ProviderModule
class. Meaning that doing the following will work as expected:
const InnerModule = ProviderModule.create({
id: 'InnerModule',
providers: [FirstService],
exports: [FirstService],
});
const OuterModule = ProviderModule.create({
id: 'OuterModule',
imports: [InnerModule],
});
const UnknownModule = ProviderModule.create({
id: 'UnknownModule',
providers: [SecondService],
exports: [SecondService],
});
InnerModule.update.addImport(UnknownModule, true); // Don't forget to provide `true` to the `addToExports` optional parameter!
const secondService = OuterModule.get(SecondService);
The OuterModule
has now access to the UnknownModule
exports because it has been dynamically imported (later at run-time) into the InnerModule
(which has been imported into OuterModule
during the bootstrap
phase)
Basically what happens is that when a module
is imported, it takes care of notify the host
module if its definiton changed.
This is a very powerful feature which comes in with some costs, most of the time negligible, but if you have an app which has thousand and thousand of modules
doing this type of dynamic behavior, you may incur in some performance issues which will require proper design to keep under control.
Most of the times the best solution is to leverage the nature of blueprints
.
Sometimes you may actually want to lazy import a module
from a file, this can be done very easily with xInjection
:
(async () => {
await MyModule.update.addImportLazy(async () => (await import('./lazy.module')).LazyModule);
MyModule.isImportingModule('LazyModule');
// => true
})();
This design pattern is extremely powerful and useful when you may have a lot of modules
initializing during the app bootstrap process as you can defer their initialization, or even never load them if the user never needs those specific modules
(this is mostly applicable on the client-side rather than the server-side)
Keep reading to understand how you can defer initialization of the modules
by using blueprints
.
The ProviderModuleBlueprint class
main purpose is to encapsulate the definitions
of a Module
, when you do ProviderModule.blueprint({...})
you are not actually creating an instance of the ProviderModule
class, but an instance of the ProviderModuleBlueprint
class.
To better understand the above concept; imagine the
blueprint
as being a dormant (static)module
which is not fully awake (dynamic) till it is actually imported into amodule
.
Whenever you import a blueprint
into a module
, it'll automatically be "transformed" to a ProviderModule
instance by the engine, this step is crucial as a blueprint
per se does not contain a container, just its definitions.
Therefore it is important to understand the injection scope
of an imported blueprint
; we previously learned that when we import a blueprint
into a module
it automatically creates an instance of the ProviderModule
from it, this means that all the singleton
providers of the blueprint
definition are now scoped singleton, where scoped means singleton in relation to their imported module.
When you initialize a blueprint
with the isGlobal property set to true
, the out-of-the-box behavior is to automatically import the blueprint
into the AppModule
. You can disable this behavior by setting the autoImportIntoAppModuleWhenGlobal property to false
const GlobalModuleBp = ProviderModule.blueprint({..., isGlobal: true }, { autoImportIntoAppModuleWhenGlobal: false });
Now you can decide when to import it into the AppModule
by doing AppModule.addImport(GlobalModuleBp)
.
I highly recommend to take advantage of the blueprints
nature in order to plan-ahead your modules
;
Why?
After you have provided the initial definitons
of a blueprint
, you can always modify them with the updateDefinition method
.
Updating the definitions
of a blueprint
after has been imported into a module
will not propagate those changes to the module
where it has been imported.
This means that we can actually leverage the blueprints
nature to defer the actual initialization of a module
by doing so:
const UserModuleBp = ProviderModule.blueprint({
id: 'UserModule',
...
});
// Later in your code
const UserModule = ProviderModule.create(UserModuleBp);
The UserModule
will be created only when necessary and it'll use the same exact definitons which are available into the UserModuleBp
at the time of the create
invokation.
This section covers advanced features which may add additional complexity (or even bugs) to your application if you misuse them, use these features only if truly needed and after evaluating the pros and cons of each.
Each module
will emit specific events through its life-cycle and you can intercept them by using the Module.update.subscribe
method.
Here you can see all the available events
If you'd need to intercept a get
request, you can achieve that by doing:
const CarModule = ProviderModule.create({
id: 'CarModule',
providers: [CarService],
});
CarModule.update.subscribe(({ type, change }) => {
// We are interested only in the `GetProvider` event.
if (type !== DefinitionEventType.GetProvider) return;
// As our `CarModule` has only one provider, it is safe to assume
// that the `change` will always be the `CarService` instance.
const carService = change as CarService;
console.log('CarService: ', carService);
});
const carService = CarModule.get(CarService);
// logs => CarService: <instance_of_car_service_here>
After subscribing to a ProviderModule
signal emission, you should make sure to also unsubscribe
if you don't need anymore to intercept the changes, not doing
so may cause memory leaks if you have lots of subscriptions
which do heavy computations!
The subscribe
method will always return a method
having the signature () => void
, when you invoke it, it'll close the pipe which intercepts the signal emitted by the module
:
const unsubscribe = CarModule.update.subscribe(({ type, change }) => {
/* heavy computation here */
});
// later in your code
unsubscribe();
Events are always invoked after middlewares
Using middlewares is not encouraged as it allows you to tap into very deep low-level code which can cause unexpected bugs if not implemented carefully, however, middlewares
are the perfect choice if you want to extend/alter the standard behavior of module
as it allows you to decide what should happen with a resolved value before it is returned to the consumer
.
Here you can see all the available middlewares
Let's say that you want to wrap all the returned values of a specific module
within an object having this signature { timestamp: number; value: any }
. By using the GetProvider
event will not do the trick because it doesn't allow you to alter/change the actual returned value to the consumer
, you can indeed alter the content via reference, but not the actual result.
So the easiest way to achieve that is by using the BeforeGet
middleware as shown below:
const TransactionModule = ProviderModule.create(TransactionModuleBp);
TransactionModule.middlewares.add(MiddlewareType.BeforeGet, (provider, providerToken, inject) => {
// We are interested only in the `providers` instances which are from the `Payment` class
if (!(provider instanceof Payment)) return true;
// or
if (providerToken !== 'LAST_TRANSACTION') return true;
// DON'T do this as you'll encounter an infinite loop
const transactionService = TransactionModule.get(TransactionService);
// If you have to inject into the middleware `context` from the `module`
// use the `inject` parameter
const transactionService = inject(TransactionService);
return {
timestamp: transactionService.getTimestamp(),
value: provider,
};
});
const transaction = TransactionModule.get('LAST_TRANSACTION');
// transaction => { timestamp: 1363952948, value: <Payment_instance> }
One more example is to add a middleware
in order to dynamically control which modules
can import a specific module
by using the OnExportAccess flag.
const UnauthorizedBranchBankModule = ProviderModule.create({ id: 'UnauthorizedBranchBankModule' });
const SensitiveBankDataModule = ProviderModule.create({
id: 'SensitiveBankDataModule',
providers: [SensitiveBankDataService, NonSensitiveBankDataService],
exports: [SensitiveBankDataService, NonSensitiveBankDataService],
});
SensitiveBankDataModule.middlewares.add(MiddlewareType.OnExportAccess, (importerModule, currentExport) => {
// We want to deny access to our `SensitiveBankDataService` from the `exports` definition if the importer module is `UnauthorizedBranchBankModule`
if (importerModule.toString() === 'UnauthorizedBranchBankModule' && currentExport === SensitiveBankDataService)
return false;
// Remaining module are able to import all our `export` definition
// The `UnauthorizedBranchBankModule` is unable to import the `SensitiveBankDataService`
return true;
});
Returning false
in a middleware
will abort the chain, meaning that for the above example, no value would be returned.
If you have to explicitly return a false
boolean value, you may have to wrap your provider value as an workaround. (null
is accepted as a return value)
Meanwhile returning true
means "return the value without changing it".
In the future this behavior may change, so if your business logic relies a lot on middlewares
make sure to stay up-to-date with the latest changes.
It is also worth mentioning that you can apply multiple middlewares by just invoking the middlewares.add
method multiple times, they are executed in the same exact order as you applied them, meaning that the first invokation to middlewares.add
will actually be the root
of the chain.
If no error is thrown down the chain, all the registered middleware callback
will be supplied with the necessary values.
It is the developer responsability to catch any error down the chain
!
If you are not interested in understanding how xInjection
works under the hood, you can skip this section 😌
It is the head of everything, a ProviderModule
is actually composed by several classes, each with its own purpose.
You can get access to all the internal instances by doing new ProviderModule({...})
instead of ProviderModule.create({...})
It is the class
which takes care of managing the registered middlewares
, check it out here.
Not much to say about it as its main role is to register and build the middleware
chain.
It is the class
which takes care of managing the inversify
container, check it out here.
Its main purpose is to initialize the module raw (InversifyJS Container) class
and to bind the providers to it.
It is the class
which takes care of managing the imported modules, check it out here.
Because modules
can be imported into other modules, therefore creating a complex graph
of modules, the purpose of this class is to keep track and sync the changes of the exports
definition of the imported module.
The ProviderModule
API is simple yet very powerful, you may not realize that doing addImport
will cause (based on how deep is the imported module) a chain reaction which the ImportedModuleContainer
must keep track of in order to make sure that the consumer module
which imported the consumed module
has access only to the providers
/modules
explicitly exported by the consumed module
.
Therefore it is encouraged to keep things mostly static, as each addProvider
, addImport
, removeImport
and so on have a penality cost on your application performance. This cost in most cases is negligible, however it highly depends on how the developer uses the feature xInjection
offers.
"With great power comes great responsibility."
It is the class
which takes care of managing the updates and event emissions of the module
, check it out here.
This class is actually the "parent" of the ImportedModuleContainer
instances, its purpose is to build the initial definition graph, and while doing so it also instantiate for each imported module a new ImportedModuleContainer
.
It also take care of managing the events
bubbling by checking cirular references and so on.
It's the "metadata" counterpart of the ProviderModule
class, as its only purpose is to carry the definitions. Check it out here.
The library does also export a set of useful helpers in the case you may need it:
import { ProviderModuleHelpers, ProviderTokenHelpers } from '@adimm/x-injection';
This covers pretty much everything about how xInjection
is built and how it works.
It is very easy to create mock modules so you can use them in your unit tests.
class ApiService {
constructor(private readonly userService: UserService) {}
async sendRequest<T>(location: LocationParams): Promise<T> {
// Pseudo Implementation
return this.sendToLocation(user, location);
}
private async sendToLocation(user: User, location: any): Promise<any> {}
}
const ApiModuleBp = new ProviderModule.blueprint({
id: 'ApiModule',
providers: [UserService, ApiService],
});
// Clone returns a `deep` clone and wraps all the `methods` to break their reference!
const ApiModuleBpMocked = ApiModuleBp.clone().updateDefinition({
id: 'ApiModuleMocked',
providers: [
{
provide: UserService,
useClass: UserService_Mock,
},
{
provide: ApiService,
useValue: {
sendRequest: async (location) => {
console.log(location);
},
},
},
],
});
Now what you have to do is just to provide the ApiModuleBpMocked
instead of the ApiModuleBp
😎
Comprehensive, auto-generated documentation is available at:
👉 https://adimarianmutu.github.io/x-injection/index.html
You want to use it within a ReactJS project? Don't worry, the library does already have an official implementation for React ⚛️
For more details check out the GitHub Repository.
Pull requests are warmly welcomed! 😃
Please ensure your contributions adhere to the project's code style. See the repository for more details.
xInjection
For questions, feature requests, or bug reports, feel free to open an issue on GitHub.