@6pm/depend

1.0.0 • Public • Published

@6pm/depend

npm version dependencies Status Build Status Coverage Status

Declarative dependency injection.

Table of contents

Install

npm install @6pm/depend --save

Test

To run the unit test suite.

git clone https://github.com/6pm-js/depend.git
cd depend
npm install
npm test

To run nyc coverage tests.

git clone https://github.com/6pm-js/depend.git
cd depend
npm install
npm run cover

This will result in a textual summary display, and a detailed html coverage report created in /coverage.

Usage

Usage requires two distinct components, declaration of dependencies and injection points - and runtime binding of concrete implementations, and creation of instances / satisfying of dependencies.

Declarative vs imperative

Why support two non-standard ES extensions? Both class properties, and decorators are in a state of flux, not yet stable in any upcoming ES spec (at the time of writing), but there are available implementations that work, and there is something eminently readable about declarative approaches.

The imperative mechanism allows backwards compatible integration with existing libraries, or the safer path, if desired - but the declarative approach is a personal preference, as I find it considerably less surprising, and more explicit about intent.

There is no need to intrument to allow the declarative, decorator approach, unless you intend to use it within your project, and no capability is unique to either approach - choose whichever you are comfortable with.

In order to use the declarative approach (at the time of writing), two transforms are needed, via Babel transpilation:

Declaring dependencies

Dependencies are declared using either the declarative decorator syntax:

import { Inject } from '@6pm/depend';
import { InjectedType } from 'wherever'

class SomeClass {

	@Inject(InjectedType)
	property = null;

}

or imperative syntax:

import { Depend } from '@6pm/depend';
import { InjectedType } from 'wherever'

class SomeClass {}

Depend.inject(SomeClass, 'property', InjectedType);

Dependencies can be attached to the whole class, in which case they apply to the constructor function, supplying the parameters to it, when created via a Container:

import { Inject, Depend } from '@6pm/depend';
import { InjectedTypeA, InjectedTypeB } from 'wherever'

// Declarative approach
@Inject(InjectedType, InjectedTypeB)
class SomeClass1 {

	constructor(injectedInstanceA, injectedInstanceB) {}

}


// Imperative approach
class SomeClass2 {

	constructor(injectedInstanceA, injectedInstanceB) {}

}
Depend.inject(SomeClass, null, InjectedTypeA, InjectedTypeB);
// The ^null is important, it specifies that we're not targeting a property of
// SomeClass2, but the constructor.

The legacy decorator plugin, for Babel, does not allow decoration of constructors directly - which is why the declarative approach must decorate the class, rather than the actual constructor function.

Dependencies can also be injected directly into class methods, which are treated as setter functions:

import { Inject, Depend } from '@6pm/depend';
import { DatabaseAbstraction } from './database.js'

// Declarative approach
class SomeClass1 {

	@Inject(DatabaseAbstraction)
	setDatabaseAbstraction(dbAPI) {}
}


// Imperative approach
class SomeClass2 {

	setDatabaseAbstraction(dbAPI) {}

}
Depend.inject(SomeClass, 'setDatabaseAbstraction', DatabaseAbstraction);

Creating instances, and satisfying dependencies

Once a class is declared as managed by depend, it should not be manually instantiated with new, but instead created via a managed Container. The Container ensures that any dependencies declared are satisified, including sub-dependencies before returning an instance.

import { Inject, Container } from '@6pm/depend';
import { DatabaseAbstraction } from './database.js'

@Inject(DatabaseAbstraction)
class SomeClass {

	constructor(databaseAbstraction) {
		// This will be called prior to create, below, completing.
	}

}

// A new instance of DatabaseAbstraction will be created, and provided to the
// SomeClass constructor, prior to the new SomeClass instance being returned.
let instance = new Container().create(SomeClass);

Runtime determination of concrete implementations

The Container class does much more than just create instances, and satisfy dependencies - it can also be used to specify alternate implementations to use at runtime, when a particular dependency type is encountered.

This is achieved using the .bind() method on the Container instance.

import { Inject, Container } from '@6pm/depend';
import { DatabaseAbstraction } from './database.js'
import { DatabaseMySQL } from './database-mysql.js'

@Inject(DatabaseAbstraction)
class SomeClass {

	constructor(databaseAbstraction) {
		// This will be called prior to create, below, completing.
	}

}

// A new instance of DatabaseMySQL will be created, and provided to the
// SomeClass constructor, prior to the new SomeClass instance being returned.
let instance = new Container()
	.bind(DatabaseAbstraction, DatabaseMySQL)
	.create(SomeClass);

Post injection intialisation

A single function per class may be marked as an init function, which will be executed after all dependency injection has been completed on a new instance of the owning class, like so:

import { Init } from '@6pm/depend';

class SomeClass {

	@Init
	someInitFunction() {
		// This will only execute once a Container.created instance of SomeClass
		// has been fully created, and all dependencies injected.
	}

}

or, imperatively:

import { Depend } from '@6pm/depend';

class SomeClass {

	someInitFunction() {
		// This will only execute once a Container.created instance of SomeClass
		// has been fully created, and all dependencies injected.
	}

}

Depend.init(SomeClass, 'someInitFunction');

Inheritance

Dependencies are inherited using the ES2016 class format, or traditional prototypical inheritance. So extending a managed class automatically confers all requirements, and determines overloaded requirements, too.

class A {

	@Inject(SomeType)
	property;

}

class B extends A {}

class C extends A {

	@Inject(AlternativeType)
	property;

}

In the above example, creating an instance of B will inject SomeType into property, as an inherited requirement. Creating an instance C will inject an instance of AlternativeType, instead, without requiring, or creating an instance of SomeType first.

Typical phases of a dependency injected application

The phases of a dependency injected application are typically:

  • Declaration of intent / dependencies

    • Using @Inject, @Abstract and @Singleton to declare intent, and requirements.
    • This typically occurs throughout the application, where ever components reside.
  • Configuration / binding

    • Creating a Container instance, and binding concrete implementations
    • This typically occurs in a single setup file that applies configuration for a given environment.
  • Runtime usage

    • Wherever new instances are required, the Container instance created in the configuration / binding step is used to create new instances, and automatically satisfy any required dependencies.

As an example:

  • Declaration:

./service.js;

import { Inject, Singleton } from '@6pm/depend';

class Service {

	@Inject(Logger)
	logger;

	start() {
		logger.log('Started!');
	}
}

./interface/someLogger.js;

class Logger {

	@Abstract
	log(message) {}

}

./implementation/console-logger.js;

class ConcreteConsoleLogger {

	log(message) {
		console.log(message);
	}

}
  • Configuration / binding

./configuration/container.js

import { Container } from '@6pm/depend';

import Logger from './interface/logger';
import ConcreteLogger from './implementationconsole-logger';

function configure() {
	let container = new Container();

	container.bind(Logger, ConcreteLogger);

	return container;
}

export let container = configure();
  • Runtime usage:

main.js

import Service from './service.js';
import container from './configuration/container.js';

let service = container.create(Service);
service.start();

As Promised

Some dependencies cannot be ready for injection until they have accessed remote resources, whether to obtain configuration, or connections, resources, etc.

In order to facilitate this, without introducing surprising levels of variance in return types, Promises may be returned from three key points within the dependency injection lifecycle:

  • constructor
  • injected property setter
  • init

If any of the above methods return a Promise then container.create will also return a Promise that resolves to the new instance if, and only if any and all Promises returned resolve successfully - or reject on the first that rejects.

If all three (or any combination) of the above methods return Promises, then they are guaranteed to resolve in the oder listed above, and reject in the same order.

If multiple injected setters are declared on a single class, and return multiple Promises, then they will occur, effectively, simultaneously, with no order guarantees between them, but they will be sychronised using Promise.all prior to an init function being executed.

Downstream dependencies as Promises

In order to:

  1. Prevent surprise (and cascading requirements to handle Promises)
  2. Ensure control flow options remain the preserve of the application developer

Downstream dependencies that return Promises DO NOT automatically promote an upstream type to a Promise.

That is, if, say, class A declares a dependency injection of an instance of class B, and B is a Promise, creating an instance of A will not result in a Promise, unless it explicitly returns a Promise as described in the previous section.

As mentioned above, this prevents surprise (concrete implementation C is bound as runtime configuration, and resolves to a Promise, where B did not), and ensures that the application programmer can choose how to handle such a situation - the dependency injected instance of A may not need the downstream dependency to be resolved from a Promise prior to being created - automatically doing so would create an automatic synchronicity between steps, preventing, say, the ability to handle resolving of a Promise, and triggering of any further logic as a consequence, asynchronously.

Handling of such downstream dependencies is therefore left as an exercise to the application developer, and any injected dependencies that resolve to a Promise will merely deliver the Promise - if dependency injection should wait for resolution of a Promised dependency, that can cascaded manually, by returning a Promise from any of the previously mentioned lifecycle methods, or, using async / await syntax, if available, like so:

import { Inject, Container } from '@6pm/depend';
import { DatabaseAbstraction } from './database.js'

@Inject(DatabaseAbstraction)
class SomeService {

	async constructor(databaseAbstraction) {
		this.db = await databaseAbstraction;
		return this;
	}

	@Init
	init() {
		// This method will only be called if the construction succeeded, and
		// the `.db` property has successfully resolved, and is available.
	}

}


async function startService() {
	try{
		let someService = await new Container()
			// .bind() any runtime configuration here
			.create(SomeService);

		// someService will now exist, with a fully resolved `.db` property.
		someService.start();

	}catch(e) {
		// someService failed to instantiate, the causal error (from
		// DatabaseAbstraction, or the bound concrete implementation, thereof),
		// will be caught here.
	}
}

startService();

// Alternatively, using the raw `Promise` flow.
new Container()
	// .bind() any runtime configuration here
	.create(SomeService)
	.then(function(someService) {
		// Fully resolved `someService` instance now available to use, with a
		// resolved `.db` property.
		someService.start();
	}).catch(function(error) {
		// If the `DatabaseAbstraction` rejects for some reason, `someService`
		// instantiation will fail, calling this function with the causal Error.
	});

Singletons

In addition to supplying instances of types, and binding them dynamically at runtime, certain classes may be designed to be shared, requiring one, and only one instance, which should to be passed to any declared dependency. This can be achieved by declaring a class as a singleton, using either a declarative decorator, or imperative style.

import { Singleton, Depend } from '@6pm/depend';

@Singleton
class A {}

class B {}
Depend.singleton(B);

Any class declaring a dependency of A, or B will only instantiate a single instance, and all will receive that single instance.

This is particularly useful for injecting common implementations, such as a datastore abstraction (that, say, handles connection pooling under the hood), or a configured logging interface, or arbitration mechanism for contentious resources, common application configuration, etc.

Interfaces in a language without interfaces

JavaScript does not have any real concept of interfaces, so the depend library provides a mechanism to allow an effective approximation of the concept.

Methods, or entire classes, can be declared as abstract via the decorator or imperative approach, as so:

import { Abstract, Depend } from '@6pm/depend';

@Abstract
class A {}

class B {

	@Abstract
	someMethod() {}

}

class C {}
Depend.abstract(C);

The abstract property is automatically conferred to the owning class, if a method is declared abstract, and has the following effects:

  • For methods - any attempt to invoke the method will throw an Error that the method is abstract, and should be overloaded in a subclass.

  • For classes - any attempt to create an instance of the class, via a container, will throw an Error indicating that the class is abstract, and a concrete extension of the class should be used instead.

While the depend library, and JavaScript do not validate that the contract of an interface is honoured, this adds a degree of safety, as a failure to bind a concrete implementation will result in the creation of a new instance failing, should an abstract class be requested at any stage as a dependency - which ensures fast failure, at startup - and the potential to catch an erroneous configuration during an integration test of production configuration.

Destructured injection

In addition to specifying types to be resolved during dependency injection, the depend library can accept a limited form of destructured assignment, providing more complex structures, such as array literals, and object literals, like so:

@Inject({ database: DatabaseAbstraction, logger: LoggerAbstraction })
class SomeClass {

	constructor(options) {
		this.database = options.database;
		this.logger = options.logger;
	}

}

Any depth of array and object literal definitions will be traversed and injected as needed - which works particularly well with ES2016 destructured assignment:

@Inject([ { sub: A }, [ B, C ] ])
class SomeClass {

	constructor([ { sub:a }, [ b, c ] ]) {
		// a, b and c receive instances of A, B and C respectively.
	}

}

Cyclic dependencies

The depend library automatically determines if dependencies cause cycles, both at the point of declaration of dependencies, and when binding concrete implementations, and will throw if such a situation occurs - since cyclical dependencies can never be correctly satisfied.

If A requires an instance of B, that then requires and instance of C, that then requires A, there is no way to determine a satisfied state for any instance along the cyclical chain.

If a cycle is detected, an Error is thrown from either the dependency declaration (the @Inject or Depend.inject), or the bind` call that causes the cycle.

Performance

The depend library is designed to be very performant, using two primary methods:

  • Front loading as much work as possible, to ensure that creating, and satisfying new instances is as cheap as possible. In particular, cycle detection occurs during the declaration of dependencies, and the container binding stage - ensuring that the cost does not affect run time performance, and usage patterns.

  • Dynamic code generation, to allow the ES runtime / compiler to optimise away as much work as possible - while a contentious technique, this is never visible externally, but testing during development, (via microbenchmarks - YMMV), proved to provide more than 3 orders of magnitude difference in instance creation.

In addition, the dynamic code generation, on v8 in particular, adds very little startup overhead.

Contributing

All issues, requests, questions, PRs, improvements, or merely comments welcome! All I ask is to attempt to conform to the rather loose house style, if opening a pull request!

Package Sidebar

Install

npm i @6pm/depend

Weekly Downloads

1

Version

1.0.0

License

MIT

Last publish

Collaborators

  • 6pm