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
blueprintas being a dormant (static)modulewhich 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.
xInjectionFor questions, feature requests, or bug reports, feel free to open an issue on GitHub.