dandi

🌻A modular DI, MVC, and Model binding/validation framework for NodeJS and TypeScript or ES6

View the Project on GitHub just-dandi/dandi

@dandi/core

Dandi’s dependency injection is heavily influenced by Angular’s DI system.

Concepts

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: 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

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: InjectionToken<string> = 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: 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.

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.

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

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()

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

OnConfig

Logging

See documentation for @dandi/core/logging