@dandi/core
v1.0.0-alpha.77
Published
Dandi's dependency injection is heavily influenced by [Angular](https://angular.io)'s DI system.
Downloads
36
Readme
@dandi/core
Dandi's dependency injection is heavily influenced by Angular's DI system.
Concepts
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.
Patterns
Declaration Merging for Injection Tokens
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) { }
}
Describing Injectable Services
@Injectable() Decorator
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 = 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
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.
Value Providers
A value provider allows mapping an existing value to an injection token.
import { InjectionToken, Provider, SymbolToken } from '@dandi/core'
const SomeValue = SymbolToken.for<string>('SomeValue')
const SomeValueProvider: Provider<string> = {
provide: SomeValue,
useValue: 'any-value-you-like-here',
}
Factory Providers
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,
}
Class Providers
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 = 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
.
Describing Dependencies
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 {}
}
Optional Dependencies
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) {}
}
Service Discovery
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.
Modules
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)],
})
Injectable Instances and the Injector Hierarchy
Injector Hierarchy
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.
Injectable Instances
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.
Scope Restrictions
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
.
- Injection tokens can define a scope restriction if they are instances of
OpinionatedInjectionToken
, and set therestrictScope
option - Provider objects specify a scope restriction by including a
restrictScope
key - Classes can specify a scope restriction by passing the
RestrictScope
modifier with the@Injectable
decorator:@Injectable(RestrictScope(MyCustomScope))
Default Scoping
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.
Restricted Scope
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.
Scope Behaviors
A ScopeBehavior
allows additional options for controlling how and where injectable instances are created.
perInjector
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)
Application Lifecycle
app.start()
orapp.run()
- Pre-Init - Providers defined in container configuration are registered with the internal provider repository
- Init -
InjectorContextFactory
is bound, scanners are run - Config - Provided implementations of
OnConfig
are invoked - Bootstrap - The
EntryPoint
implementation, if provided, is instantiated and invoked
Application Startup and Bootstrapping
The 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 aPromise
that resolves to theInjector
instance created by the application. The injector can then be used to invoke or inject objects configured in the application.app.run()
returns aPromise
that resolves to the value returned by the configuredEntryPoint
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()
Application Configuration
- TODO
OnConfig
- TODO
Logging
See documentation for @dandi/core/logging