🌻A modular DI, MVC, and Model binding/validation framework for NodeJS and TypeScript or ES6
Dandi’s dependency injection is heavily influenced by Angular’s DI system.
Injection Token - A value that represents an injectable dependency. An Injection token can be a class
constructor, or a Symbol
value. Injection tokens represent a contract or logical concept within the framework or
an application, without caring about its implementation.
Provider - An object which describes the implementation of the contract of concept represented by an Injection Token. The implementation can be an constant value, or can be generated using a class constructor or factory function.
Injector - The service responsible for creating instances of injectables.
Dandi frequently uses InjectionToken
objects to represent contracts or services defined only using an interface.
Rather than give the interface and token separate names (and by convention, different casing), Dandi uses identical
names, including casing, for interfaces and their corresponding injection tokens. This is possible due to TypeScript’s
declaration merging feature, and provides a
more consistent feel when injecting services that use this pattern:
// injection token
export const SomeService = SymbolToken.for('SomeService')
// interface
export interface SomeService {
doTheWork(): void
}
// usage
class MyClass {
constructor(@Inject(SomeService) private someService: SomeService) { }
}
The simplest method of describing an injectable service is to add the @Injectable()
decorator to it. This tells the
injector that when it encounters a dependency of the decorated class, it will instantiate a new instance of that class:
import { Injectable } from '@dandi/core'
@Injectable()
class MyService {}
The @Injectable()
decorator can also be used to register a service for
a different injection token, such as a token representing an interface:
// my-interface.ts
import { InjectionToken, SymbolToken } from '@dandi/core'
export interface MyInterface {}
export const MyInterface: InjectionToken<MyInterface> = SymbolToken.for<MyInterface>('MyInterface')
// my-service.ts
import { Injectable } from '@dandi/core'
import { MyInterface } from './my-interface'
@Injectable(MyInterface)
export class MyService implements MyInterface {}
Providers allow you to configure the injector to map any kind of token to any value or implementation. They are most commonly used to register implementations of interfaces.
A value provider allows mapping an existing value to an injection token.
import { InjectionToken, Provider, SymbolToken } from '@dandi/core'
const SomeValue: InjectionToken<string> = SymbolToken.for<string>('SomeValue')
const SomeValueProvider: Provider<string> = {
provide: SomeValue,
useValue: 'any-value-you-like-here',
}
A factory provider allows mapping a factory function to an injection token. This can be helpful for making 3rd party classes injectable.
import { InjectionToken, Provider, SymbolToken } from '@dandi/core'
import { S3 } from 'aws-sdk'
export function s3Factory(): S3 {
return new S3({ endpoint: 'http://local-dev-endpoint' })
}
export const S3Provider: Provider<S3> = {
provide: S3,
useFactory: s3Factory,
}
A class provider allows mapping a class constructor to an injection token.
import { InjectionToken, Provider, SymbolToken } from '@dandi/core'
export interface MyInterface {}
export const MyInterface: InjectionToken<MyInterface> = SymbolToken.for<MyInterface>('MyInterface')
export class MyService implements MyInterface {}
export const MyInterfaceProvider: Provider<MyInterface> = {
provide: MyInterface,
useClass: MyService,
}
In the above example, MyInterfaceProvider
allows requests for
MyInterface
to be resolved as instances of MyService
.
Use the @Inject()
decorator to describe dependencies in a constructor:
@Injectable()
class ServiceA {
public getSomething(): Promise<Something> {
...
}
}
@Injectable()
class ServiceB {
constructor(
@Inject(ServiceA) private serviceA: ServiceA,
) {}
public async doSomething(): Promise<void> {
const something = await this.serviceA.getSomething()
console.log(something)
}
}
The @Inject()
decorator can also be used to describe dependencies for
a function or method. While Dandi does not automatically wrap function
calls, decorated functions can be invoked by an Injector
’s invoke
method:
@Injectable()
class MyService {
constructor(@Inject(Injector) private injector: Injector) {}
public async doSomething(): Promise<void> {
await this.injector.invoke(this, 'invokableMethod') // returns a Promise
}
public invokableMethod(@Inject(MyDependency) myDep: MyDependency): void {
}
}
Normally, if the injector cannot find a provider for a dependency, it will throw an error. However, dependencies can be
marked as optional using the @Optional()
decorator. Optional dependencies that cannot be resolved will be passed as
undefined
.
class MyService {
constructor(
@Inject(MyDependency) @Optional() private myDep: MyDependency,
) {}
}
Classes and providers that are used by an application must be configured with the DandiApplication
instance:
import { DandiApplication } from '@dandi/core'
const app = new DandiApplication({
providers: [
MyService,
MyInterfaceProvider,
],
})
Values passed to the providers
property can be class constructors, Provider
instances, Module
instances, or
arrays of any combination thereof. Additionally, arrays of injectables can be nested as desired - the DI system will
unpack any level of nesting.
In Dandi, a Module
is a grouping dependencies, allowing many dependencies to be registered in an application with a
single line of code. Additionally, modules may expose helper methods for configuring the services or settings included
with the module.
import { DandiApplication } from '@dandi/core'
import { ConsoleLogListener, LoggingModule } from '@dandi/core/logging'
import { PrettyColorsLogging } from '@dandi/logging'
const app = new DandiApplication({
providers: [
LoggingModule.use(ConsoleLogListener, PrettyColorsLogging),
]
})
The Dandi DI system starts with a “root” injector, where all the providers defined in the application’s configuration
are registered. When executing a dependency injection request (inject
or invoke
), a child injector is created to
service the specific request. This injector is given an InjectionScope
, which identifies the purpose for which the
injector was created. Each injector has access to the providers registered to it as well as all of its parents.
Creating a child injector can also be done manually using Injector.createChild(scope, ...providers)
. Creating a child
injector also provides an opportunity to add additional providers for the new injector.
Each provider can create at most one instance per injector. When an instance is created, a reference to that instance is stored with the injector matching its scope restriction as described below, or the injector where the provider is registered. Subsequent requests for the same injection token within the same scope will result in the same instance being injected.
A scope restriction can be defined on either a provider or an injection token. If both a provider and an injection token
define a restriction, the restriction from the injection token is used. A scope restriction may be any value included
in the InjectionScope
type - a class constructor, a function, a DependencyInjectionScope
instance, or an object
implementing CustomInjectionScope
.
OpinionatedInjectionToken
, and set the
restrictScope
optionrestrictScope
keyRestrictScope
modifier with the @Injectable
decorator:
@Injectable(RestrictScope(MyCustomScope))
When neither a provider nor its injection token specifies a scope restriction, the provider will be used to generate a single instance, which will be scoped to the injector where the provider was registered. For example, if a provider is specified in an application’s configuration, it will only ever be used to create one instance, and that instance will be reused throughout the entire application, no matter which level of the injector hierarchy injects it.
When a scope restriction is defined, the injectable will be restricted to being injected only as a child of a scope matching the defined restriction. Restricting scope is useful any time an application is processing multiple streams of data that must remain separate - for example, handling HTTP requests. When creating instances of scope restricted injectables, the instance is created and stored in the first injector that both has access to a provider providing the requested injection token, and has, or is a child of a matching injection scope.
A ScopeBehavior
allows additional options for controlling how and where injectable instances are created.
ScopeBehavior.perInjector
forces a new instance of an injectable to be created for each injector that uses it. It can
also be invoked with an InjectionScope
, which will add a scope restriction on top of the perInjector
behavior:
ScopeBehavior.perInjector(MyCustomScope)
app.start()
or app.run()
InjectorContextFactory
is bound, scanners are runOnConfig
are invokedEntryPoint
implementation, if provided, is instantiated and invokedThe container’s start()
or run()
method must be called to initialize the
container and start the application.
import { DandiApplication } from '@dandi/core'
const app = new DandiApplication({
providers: [
MyService,
MyInterfaceProvider,
],
})
app.run()
app.start()
returns a Promise
that resolves to the Injector
instance created by the application.
The injector can then be used to invoke or inject objects configured in the application.
app.run()
returns a Promise
that resolves to the value returned by the configured EntryPoint
implementation.
Startup logic is defined by providing an implementation of the EntryPoint
interface:
import { EntryPoint, DandiApplication, Inject, Injectable } from '@dandi/core'
@Injectable(EntryPoint)
class MyApp implements EntryPoint {
constructor(
@Inject(MyService) private myService: MyService,
) {}
public run(): void {
// start the app
this.myService.listen()
}
}
const app = new DandiApplication({
providers: [
MyApp,
MyService,
MyInterfaceProvider,
],
})
app.run()
See documentation for @dandi/core/logging