Light-weight and type-safe Dependency Injection.
Simple, light-weight, lazy-instantiating, and type-safe dependency injection in Typescript! Perfect for use in Lambdas and unit test friendly.
It is built on top of BottleJS, with a simple type-safe
wrapper. The original bottle
is available for more advanced use, though. Even if you are not using Typescript,
you may still prefer this simplified interface over using BottleJS directly.
As of v3, Injector also supports a Typescript decorator for registering classes.
Injector
depends on one other utility to work:
- Sailplane Logger
npm install @sailplane/injector @sailplane/logger [email protected]
If you wish to use the Typescript decorator, these options must be enabled in tsconfig.json
:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
If using esbuild, a plugin such as esbuild-decorators is necessary.
Use Injector.register(className)
to register a class with the Injector. Upon the first call
to Injector.get(className)
, the singleton instance will be created and returned.
Example without decorator:
import {Injector} from "@sailplane/injector";
export class MyService {
}
Injector.register(MyService);
// Elsewhere...
const myService = Injector.get(MyService)!;
Example with decorator:
import {Injector, Injectable} from "@sailplane/injector";
@Injectable()
export class MyService {
}
// Elsewhere...
const myService = Injector.get(MyService)!;
Use Injector.register(className, dependencies: [])
to register a class with constructor
dependencies with the Injector. Upon the first call to Injector.get(className)
, the
singleton instance will be created and returned.
dependencies
is an array of either class names or strings with the names of things.
Example without decorator:
import {Injector} from "@sailplane/injector";
export class MyHelper {
}
Injector.register(MyHelper);
Injector.registerConstant('stage', 'dev');
export class MyService {
constructor(private readonly helper: MyHelper,
private readonly stage: string) {
}
}
Injector.register(MyService, [MyHelper, 'stage']);
Example with decorator:
import {Injector, Injectable} from "@sailplane/injector";
@Injectable()
export class MyHelper {
}
Injector.registerConstant('stage', 'dev');
@Injectable({ dependencies: [MyHelper, 'stage'] })
export class MyService {
constructor(private readonly helper: MyHelper,
private readonly stage: string) {
}
}
Define your class with a static $inject
member as an array of either class names or strings
with the names of registered dependencies, and a matching constructor that accepts the dependencies
in the same order. Then use Injector.register(className)
to register a class.
Upon the first call to Injector.get(className)
, the
singleton instance will be created with the specified dependencies.
This functions just like the previous use, but allows you to specify the dependencies right next to the constructor instead of after the class definition; thus making it easier to keep the two lists synchronized.
Example:
import {Injector} from "@sailplane/injector";
export class MyHelper {
}
Injector.register(MyHelper);
Injector.registerConstant('stage', 'dev');
export class MyService {
static readonly $inject = [MyHelper, 'stage'];
constructor(private readonly helper: MyHelper,
private readonly stage: string) {
}
}
Injector.register(MyService);
This approach is only applicable when not using the decorator.
If your class takes constructor parameters that are not in the dependency system, then you can register a factory function.
Use Injector.register<T>(className, factory: ()=>T)
to register a class with your
own factory function for instantiating the singleton instance.
Example without decorator:
import {Injector} from "@sailplane/injector";
export class MyHelper {
}
Injector.register(MyHelper);
export class MyService {
constructor(private readonly helper: MyHelper,
private readonly stage: string) {
}
}
Injector.register(MyService,
() => new MyService(Injector.get(MyHelper)!, process.env.STAGE!));
Example with decorator:
import {Injector, Injectable} from "@sailplane/injector";
@Injectable()
export class MyHelper {
}
@Injectable({factory: () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!)})
export class MyService {
constructor(private readonly helper: MyHelper,
private readonly stage: string) {
}
}
A common dependency injection pattern is to invent code dependencies by having business logic define an interface it needs to talk to via an abstract class, and then elsewhere have a child class define the actual behavior. Runtime options could even choose between implementations.
Here's the business logic code:
abstract class SpecialDataRepository {
abstract get(id: string): Promise<SpecialData>;
}
class SpecialBizLogicService {
constructor(dataRepo: SpecialDataRepository) {}
public async calculate(id: string) {
const data = await this.dataRepo.get(id);
// do stuff
}
}
Injector.register(SpecialBizLogicService); // Could use @Injectable() instead
Without decorators, we use Injector.register<T>(className, factory: ()=>T)
to register the implementing repository, which could be done conditionally:
Example without decorator:
import {Injector} from "@sailplane/injector";
export class LocalDataRepository extends SpecialDataRepository {
async get(id: string): Promise<SpecialData> {
// implementation ....
}
}
export class RemoteDataRepository extends SpecialDataRepository {
async get(id: string): Promise<SpecialData> {
// implementation ....
}
}
const isLocal = !!process.env.SHELL;
Injector.register(
MyService,
() => isLocal ? new LocalDataRepository() : new RemoteDataRepository()
);
Example with decorator (can't be conditional):
import {Injector, Injectable} from "@sailplane/injector";
@Injectable({as: SpecialDataRepository})
export class RemoteDataRepository extends SpecialDataRepository {
async get(id: string): Promise<SpecialData> {
// implementation ....
}
}
If you need to inject something other than a class, you can register a factory to create
anything and give it a name. This is useful if you have multiple implementations
of an interface
, and one to register one of them by the interface name at runtime. Since
interfaces don't exist at runtime (they don't exist in Javascript), you must define the name
yourself. (See previous example using an abstract base class for a more type-safe approach.)
Use Injector.registerFactory<T>(name: string, factory: ()=>T)
to register any object with your
own factory function for returning the singleton instance.
Example: Inject a configuration
import {Injector} from "@sailplane/injector";
Injector.registerFactory('config', () => {
// Note that this returns a Promise
return Injector.get(StateStorage)!.get('MyService', 'config');
});
// Elsewhere...
const config = await Injector.getByName('config');
Example: Inject an interface implementation, conditionally and no decorator
import {Injector} from "@sailplane/injector";
export interface FoobarService {
doSomething(): void;
}
export class FoobarServiceImpl implements FoobarService {
constructor(private readonly stateStorage: StateStorage) {}
doSomething(): void {
this.stateStorage.set('foobar', 'did-it', 'true');
}
}
export class FoobarServiceDemo implements FoobarService {
doSomething(): void {
console.log("Nothing really");
}
}
Injector.registerFactory('FoobarService', () => {
if (process.env.DEMO! === 'true') {
return new FoobarServiceDemo();
}
else {
return new FoobarServiceImpl(Injector.get(StateStorage)!);
}
});
// Elsewhere...
export class MyService {
static readonly $inject = ['FoobarService']; // Note: This is a string!
constructor(private readonly foobarSvc: FoobarService) {
}
}
Injector.register(MyService);
Example: Inject an interface implementation with the decorator
import {Injector, Injectable} from "@sailplane/injector";
export interface FoobarService {
doSomething(): void;
}
@Injectable({ as: "FoobarService" })
export class FoobarServiceImpl implements FoobarService {
constructor(private readonly stateStorage: StateStorage) {}
doSomething(): void {
// code
}
}
// Elsewhere...
@Injectable({dependencies: ['FoobarService']}) // Note: This is a string!
export class MyService {
constructor(private readonly foobarSvc: FoobarService) {
}
}
Injector.register(MyService);
Use Injector.registerConstant<T>(name: string, value: T)
to register any object as a
defined value. Unlike all other registrations, the value is passed in rather than being
lazy-created.
Example:
import {Injector} from "@sailplane/injector";
import {environment} from "environment";
Injector.registerConstant('environment-config', environment);
Injector.registerConstant('promisedData', asyncFunction());
// Later...
const myEnv = Injector.getByName('environment-config');
const myData = await Injector.getByName('promisedData');
See examples for another example.
Dependencies are not evaluated until the class is instantiated, thus the order of registration does not matter.
Cyclic constructor dependencies will fail. This probably indicates a design problem, but you can break the cycle by
calling Injector.get(className)
when needed (outside of the constructor), instead of injecting into the constructor.
This is a perfectly valid way of using Injector (on demand rather than upon construction). It does require that unit test mocks be registered with the Injector rather than passed into the constructor.