1.1.6 • Published 5 months ago

@adimm/x-injection v1.1.6

Weekly downloads
-
License
MIT
Repository
-
Last release
5 months ago

Table of Contents

Overview

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.

Features

  • NestJS-inspired module system: Import and export providers between modules.
  • Granular dependency encapsulation: Each module manages its own container.
  • Flexible provider scopes: Singleton, Request, and Transient lifecycles.
  • Lifecycle hooks: onReady and onDispose for module initialization and cleanup.
  • Advanced container access: Directly interact with the underlying InversifyJS containers if needed.

Installation

First, ensure you have reflect-metadata installed:

npm i reflect-metadata

Then install xInjection:

npm i @adimm/x-injection

TypeScript Configuration

Add the following options to your tsconfig.json to enable decorator metadata:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Getting Started

Bootstrapping the AppModule

In your application's entry point, import and register the global AppModule:

import { AppModule } from '@adimm/x-injection';

AppModule.register({});

Note: You must call AppModule.register() even if you have no global providers. Passing an empty object {} is valid.

Registering Global Providers

To make services available throughout your application, register them as global providers:

import { AppModule, Injectable } from '@adimm/x-injection';

@Injectable()
class LoggerService {}

@Injectable()
class ConfigService {
  constructor(private readonly logger: LoggerService) {}
}

AppModule.register({
  providers: [LoggerService, ConfigService],
});

Now, LoggerService and ConfigService can be injected anywhere in your app, including inside all ProviderModules.

Registering Global Modules

You can also import entire modules into the AppModule like so:

const SECRET_TOKEN_PROVIDER = { provide: 'SECRET_TOKEN', useValue: '123' };
const SECRET_TOKEN_2_PROVIDER = { provide: 'SECRET_TOKEN_2', useValue: 123 };

const ConfigModule = new ProviderModule({
  identifier: Symbol('ConfigModule'),
  markAsGlobal: true,
  providers: [SECRET_TOKEN_PROVIDER, SECRET_TOKEN_2_PROVIDER],
  exports: [SECRET_TOKEN_PROVIDER, SECRET_TOKEN_2_PROVIDER],
});

AppModule.register({
  imports: [ConfigModule],
});

Note: All modules which are imported into the AppModule must have the markAsGlobal option set to true, otherwise the InjectionProviderModuleGlobalMarkError exception will be thrown!

Note2: An InjectionProviderModuleGlobalMarkError exception will be thrown also when importing into the AppModule a module which does not have the markAsGlobal flag option!

Injection Scope

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.

  1. By providing the scope property to the ProviderToken:
    const USER_PROVIDER: ProviderToken<UserService> = {
      scope: InjectionScope.Request,
      provide: UserService,
      useClass: UserService,
    };
  2. Within the @Injectable decorator:
    @Injectable(InjectionScope.Transient)
    class Transaction {}
  3. By providing the defaultScope property when initializing a ProviderModule:
    const RainModule = new ProviderModule({
      identifier: Symbol('RainModule'),
      defaultScope: InjectionScope.Transient,
    });

Note: Imported modules/providers retain their original InjectionScope!

Singleton

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

Transient

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

Request

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

Provider Modules

You can define modules to encapsulate related providers and manage their scope:

import { Injectable, InjectionScope, ProviderModule } from '@adimm/x-injection';

@Injectable()
export class DatabaseService {
  // Implementation...
}

@Injectable()
export class SessionService {
  constructor(public readonly userService: UserService) {}

  // Implementation...
}

export const DatabaseModule = new ProviderModule({
  identifier: Symbol('DatabaseModule'),
  // or:  identifier: 'DatabaseModule',
  providers: [DatabaseService],
  exports: [DatabaseService],
  onReady: async (module) => {
    const databaseService = module.get(DatabaseService);

    // Additional initialization...
  },
  onDispose: async (module) => {
    const databaseService = module.get(DatabaseService);

    databaseService.closeConnection();
  },
});

export const SessionModule = new ProviderModule({
  identifier: Symbol('SessionModule'),
  defaultScope: InjectionScope.Request,
  providers: [SessionService],
  exports: [SessionService],
});

Register these modules in your AppModule:

AppModule.register({
  imports: [DatabaseModule, SessionModule],
});

Note: The AppModule.register method can be invoked only once! (You may re-invoke it only after the module has been disposed) Preferably during your application bootstrapping process.

From now on, the AppModule container has the references of the DatabaseService and the SessionService.

Inject multiple dependencies:

const [serviceA, serviceB] = BigModule.getMany(ServiceA, ServiceB);
// or
const [serviceC, serviceD] = BigModule.getMany<[ServiceC, ServiceD]>(SERVICE_TOKEN, 'SERVICE_ID');

ProviderModuleDefinition

When you do:

const MyModule = new ProviderModule({...});

The MyModule will be eagerly instantiated, therefore creating under-the-hood an unique container for the MyModule instance.

In some scenarios you may need/want to avoid that, you can achieve that by using the ProviderModuleDefinition class. It allows you to just define a blueprint of the ProviderModule without all the overhead of instantiating the actual module.

const GarageModuleDefinition = new ProviderModuleDefinition({ identifier: 'GarageModuleDefinition' });

// You can always edit all the properties of the definition.

GarageModuleDefinition.imports = [...GarageModuleDefinition.imports, PorscheModule, FerrariModuleDefinition];

Feed to new ProviderModule

const GarageModule = new ProviderModule(GarageModuleDefinition);

Feed to lazyImport

ExistingModule.lazyImport(GarageModuleDefinition);

Note: Providing it to the lazyImport method will automatically instantiate a new ProviderModule on-the-fly!

Why not just use the ProviderModuleOptions interface?

That's a very good question! It means that you understood that the ProviderModuleDefinition is actually a class wrapper of the ProviderModuleOptions.

Theoretically you can use a plain object having the ProviderModuleOptions interface, however, the ProviderModuleOptions interface purpose is solely to expose/shape the options with which a module can be instantiated, while the ProviderModuleDefinition purpose is to define the actual ProviderModule blueprint.

Lazy imports and exports

You can also lazy import or export providers/modules, usually you don't need this feature, but there may be some advanced cases where you may want to be able to do so.

The lazy nature defers the actual module resolution, this may help in breaking immediate circular reference chain under some circumstances.

Imports

You can lazily import a module by invoking the lazyImport method at any time in your code.

const GarageModule = new ProviderModule({
  identifier: 'GarageModule',
  // Eager imports happen at module initialization
  imports: [FerrariModule, PorscheModule, ...]
});

// Later in your code

GarageModule.lazyImport(LamborghiniModule, BugattiModule, ...);

Exports

You can lazily export a provider or module by providing a callback (it can also be an async callback) as shown below:

const SecureBankBranchModule = new ProviderModule({
  identifier: 'SecureBankBranchModule',
  providers: [BankBranchService],
  exports: [BankBranchService],
});

const BankModule = new ProviderModule({
  identifier: 'BankModule',
  imports: [SecureBankBranchModule],
  exports: [..., (importerModule) => {
    // When the module having the identifier `UnknownBankModule` imports the `BankModule`
    // it'll not be able to also import the `SecureBankBranchModule` as we are not returning it here.
    if (importerModule.toString() === 'UnknownBankModule') return;

    // Otherwise we safely export it
    return SecureBankBranchModule;
  }]
});

Advanced Usage

ProviderModuleNaked Interface

Each ProviderModule instance implements the IProviderModule interface for simplicity, but can be cast to IProviderModuleNaked for advanced operations:

const nakedModule = ProviderModuleInstance.toNaked();
// or: nakedModule = ProviderModuleInstance as IproviderModuleNaked;
const inversifyContainer = nakedModule.container;

You can also access the global InversifyJS container directly:

import { AppModule, GlobalContainer } from '@adimm/x-injection';

const globalContainer = GlobalContainer || AppModule.toNaked().container;

For advanced scenarios, IProviderModuleNaked exposes additional methods (prefixed with __) that wrap InversifyJS APIs, supporting native xInjection provider tokens and more.

Strict Mode

By default the AppModule runs in "strict mode", a built-in mode which enforces an opinionated set of rules aiming to reduce common pitfalls and edge-case bugs.

When invoking the AppModule.register method you can set the _strict property to false in order to permanentely disable those set of built-in rules.

Note: Do not open an issue if a bug or edge-case is caused by having the strict property disabled!

Why you should not turn it off:

MarkAsGlobal

The markAsGlobal flag property is used to make sure that modules which should be registered directly into the AppModule are indeed provided to the the imports array of the AppModule and the the other way around, if a module is imported into the AppModule without having the markAsGlobal flag property set, it'll throw an error.

This may look redundant, but it may save you (and your team) some hours of debugging in understanding why some providers are able to make their way into other modules. As those providers are now acting as global providers.

Imagine the following scenario:

const ScopedModule = new ProviderModule({
  identifier: 'ScopedModule',
  providers: [...],
  exports: [...],
});

const AnotherScopedModule = new ProviderModule({
  identifier: 'AnotherScopedModule',
  imports: [ScopedModule],
  providers: [...],
  exports: [...],
});

const GlobalModule = new ProviderModule({
  identifier: 'GlobalModule',
  markAsGlobal: true,
  imports: [AnotherScopedModule],
});

AppModule.register({
  imports: [GlobalModule],
});

At first glance you may not spot/understand the issue there, but because the GlobalModule (which is then imported into the AppModule) is directly importing the AnotherScopedModule, it means that all the providers of the AnotherScopedModule and ScopedModule (because AnotherScopedModule also imports ScopedModule) will become accessible through your entire app!

Disabling strict mode removes this safeguard, allowing any module to be imported into the AppModule regardless of markAsGlobal, increasing risk of bugs by exposing yourself to the above example.

Unit Tests

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 ApiModule = new ProviderModule({
  identifier: Symbol('ApiModule'),
  providers: [UserService, ApiService],
});

const ApiModuleMocked = new ProviderModule({
  identifier: Symbol('ApiModule_MOCK'),
  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 ApiModuleMocked instead of the ApiModule šŸ˜Ž

Documentation

Comprehensive, auto-generated documentation is available at:

šŸ‘‰ https://adimarianmutu.github.io/x-injection/index.html

ReactJS Implementation

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.

Contributing

Pull requests are warmly welcomed! 😃

Please ensure your contributions adhere to the project's code style. See the repository for more details.


For questions, feature requests, or bug reports, feel free to open an issue on GitHub!

1.1.6

5 months ago

1.1.5

5 months ago

0.8.0

6 months ago

0.7.0

6 months ago

0.6.5

6 months ago

0.6.4

6 months ago

0.6.3

6 months ago

0.6.2

6 months ago

0.6.1

6 months ago

0.6.0

6 months ago

0.5.2

6 months ago

0.5.1

6 months ago

0.5.0

6 months ago

0.4.0

6 months ago

0.3.2

6 months ago

0.3.1

6 months ago

0.2.2

6 months ago

2.0.2

6 months ago

2.0.1

6 months ago

2.0.0

6 months ago

1.1.4

6 months ago

1.1.3

6 months ago

1.1.2

6 months ago

1.1.1

6 months ago

1.1.0

6 months ago

1.0.10

6 months ago

1.0.9

6 months ago

1.0.8

6 months ago

1.0.7

6 months ago

1.0.6

6 months ago

1.0.5

6 months ago

1.0.2

6 months ago

1.0.1

6 months ago

1.0.0

6 months ago