Add IoC to TypeScript projects in node with minimal fuss and ceremony.
Dependency injection helps to break explicit dependencies between objects making it much easier to maintain a single responsibility and reduce coupling in our class designs. This leads to more testable code and code that is more resilient to change.
Most arguments for or against DI focus on testing, and given how easy it is to mock objects in JavaScript, you don't really need a framework. If testing were the only virtue they'd be spot on. Despite its virtues DI doesn't come without its own problems. However for larger projects that you expect to be long-lived, a DI framework may help manage the complexity.
For a deeper background on Dependency Injection consider the Wikipedia article on the subject.
Assuming you've embraced the general concept of DI why would you want to use a framework. Lets consider the alternatives.
class Hunter {
private weapon: Weapon = new Weapon()
}
In this scenario the Hunter class knows how to create a weapon and provides a sane default, but allows the dependency to be overridden if needed.
PROS
CONS
class Hunter {
constructor( private readonly weapon: Weapon ) {}
}
Here Hunters can use any weapon and can be designed to an interface Weapon that does not have an implementation yet.
PROS
CONS
Using a good framework preserves the benefits of each method while minimizing the cons. A DI framework works like an automatic factory system resolving dependencies cleanly like a factory but without all the effort to create custom factories.
A good framework should
class Hunter {
// Must await to access injected resource
@Inject private weapon?: Promise<Weapon>
// or use constructor that always receives resolved instances
constructor( @Inject private weapon: Weapon ) {}
}
Here the dependency is clearly defined - and even creates accessors for getting and setting the weapon. When a Hunter is created its dependencies are also created - and any of their dependencies and so on. Usage is equally simple
const hunter = await scorpion.fetch( Hunter )
hunter.weapon // => a Weapon
Overriding the kind of weapons used by hunters.
class Axe extends Weapon {}
scorpion.prepare(map => {
map.bind(Axe)
})
hunter = await scorpion.fetch( Hunter )
hunter.weapon // => an Axe
Overriding hunters!
class Axe extends Weapon {}
class Predator extends Hunter {}
scorpion.prepare(map => {
map.bind(Predator)
map.bind(Axe)
})
hunter = await scorpion.fetch( Hunter )
hunter // => Predator
hunter.weapon // => an Axe
Add scorpion to your project
npm install scorpion-ioc
# or using yarn
yarn add scorpion-ioc
Out of the box Scorpion does not need any configuration and will work immediately. You can hunt for any Class even if it hasn't been configured.
const now = await scorpion.fetch( Date )
now // => Date
Scorpions feed their prey - any object that should be fed its dependencies when it is created. Simply add the @Inject annotation for any dependency that you want resolved.
class Keeper {
constructor( @Inject private readonly lunch?: FastFood ) {}
}
class Vet {}
class Zoo {
constructor(
@Inject private readonly keeper: Keeper,
@Inject private readonly vet: Vet,
) {}
}
const zoo = await scorpion.fetch( Zoo )
zoo.keeper // => an instance of a Keeper
zoo.vet // => an instance of a Vet
zoo.keeper.lunch // => an instance of FastFood
All of your classes should be objects! And any dependency that is also an Object will be fed.
A good scorpion should be prepared to hunt. An effort that describes what the scorpion can find for and how it should be found. Scorpion uses Classes as the primary means of identifying dependency in favor of opaque labels or strings. This serves two benefits:
Most scorpion hunts will be for an instance of a specific class (or a more derived class). If you bind a more concrete implementation and ask for the base class, the more concrete version will be used.
class User {}
class Employee extends User {}
await scorpion.fetch( User ) // => new User()
scorpion.prepare( map => {
map.bind( Employee )
})
await scorpion.fetch( User ) // => Employee.new()
Sometimes resolving the correct dependencies is a bit more dynamic. In those cases you can use a builder block to hunt for dependency.
class Sword {}
class Samurai extends Sword {}
class Broad extends Sword {}
scorpion.prepare( map => {
map.bind( Sword, async (fetcher, ...args) =>
scorpion.fetch( Math.random() * 2 > 1 ? Samurai : Broad )
)
})
Objects may also define their own static .create
methods that receive a
fetcher and arguments.
class City {
static async create( fetcher, name ): Promise<City> {
let klass
if( name == "New York" ) {
klass = BigCity
} else {
klass = SmallCity
}
return fetcher.fetch( klass, name )
}
constructor( private readonly name: string ) {}
}
class BigCity extends City {}
class SmallCity extends City {}
Scorpion allows you to capture dependency and feed the same instance to everyone that asks for a matching dependency.
DI singletons are different then global singletons in that each scorpion can have a unique instance of the class that it shares with all of its objects. This allows, for example, global variable like support per HTTP request without polluting the global namespace or dealing with thread concurrency issues.
class Logger {}
scorpion.prepare( map => {
map.capture( Logger )
}
await scorpion.fetch( Logger ) // => Logger.new
await scorpion.fetch( Logger ) // => Previously captured logger
Captured dependencies are not shared with child scorpions (for example when conceiving scorpions from a Nest. To share captured dependency with children use share.
A scorpion nest is where a mother scorpion lives and conceives young - duplicates of the mother but maintaining their own captured singletons. You might prepare a module scoped nest and then conceive a new Scorpion for each request. That way all preparation performed by the mother is shared with all the children it conceives so that configuration is established when the application starts.
const Logger {}
const SystemLogger extends Logger {}
const nest = new Nest( map => {
map.bind( SystemLogger )
})
// In HTTP request startup code
await scorpion = nest.conceive()
await scorpion.fetch( Logger ) // => SystemLogger.new
git checkout -b my-new-feature
)git commit -am 'Add some feature'
)git push origin my-new-feature
)Copyright (c) 2018 Paul Alexander
Generated using TypeDoc