Originally posted at michaelzanggl.com. Subscribe to my newsletter to never miss out on new content.
In the previous post of this series we were implementing our very own ioc container by creating bindings with ioc.bind
and ioc.singleton
.
But this setup can be a little cumbersome. That's why many frameworks also come with automatic dependency injection.
Laravel can do this thanks to PHP's typehinting mechanism
public function __construct(UserRepository $users)
{
$this->users = $users;
}
Angular makes use of TypeScript's emitDecorateMetadata.
class Pterodactyls {}
@Component({...})
class Park {
constructor(x: Pterodactyls, y: string) {}
}
But these luxuries don't come in vanilla JavaScript. So in this article we will implement automatic injection in a similar fashion it was done on the MVC framework Adonis.js.
You can find the complete code on the same GitHub as in the last post.
We start off with (a little improved version of) the code from last time:
module.exports = function createIoC(rootPath) {
return {
_container: new Map,
_fakes: new Map,
bind(key, callback) {
this._container.set(key, {callback, singleton: false})
},
singleton(key, callback) {
this._container.set(key, {callback, singleton: true})
},
fake(key, callback) {
const item = this._container.get(key)
this._fakes.set(key, {callback, singleton: item ? item.singleton : false})
},
restore(key) {
this._fakes.delete(key)
},
_findInContainer(namespace) {
if (this._fakes.has(namespace)) {
return this._fakes.get(namespace)
}
return this._container.get(namespace)
},
use(namespace) {
const item = this._findInContainer(namespace)
if (item) {
if (item.singleton && !item.instance) {
item.instance = item.callback()
}
return item.singleton ? item.instance : item.callback()
}
return require(path.join(rootPath, namespace))
}
}
}
The idea is to avoid newing up classes manually and using a new method ioc.make
instead. Let's write the simplest test we can think of.
describe('auto injection', function() {
it('can new up classes', function() {
const SimpleClass = ioc.use('test/modules/SimpleClass')
const test = ioc.make(SimpleClass)
expect(test).to.be.instanceOf(SimpleClass)
})
})
And SimpleClass
looks like this
// test/modules/SimpleClass.js
class SimpleClass {}
module.exports = SimpleClass
Running the test should fail because we have not yet implemented ioc.make
. Let's implement it in index.js
const ioc = {
// ...
make(object) {
return new object
}
}
The test passes!
But it is a little annoying to always have to first do ioc.use
and then ioc.make
to new up classes. So let's make it possible to pass a string into ioc.make
that will resolve the dependency inside.
A new test!
it('can make classes using the filepath instead of the class declaration', function() {
const test = ioc.make('test/modules/SimpleClass')
expect(test).to.be.instanceOf(ioc.use('test/modules/SimpleClass'))
})
and ioc.make
becomes
if (typeof object === 'string') {
object = this.use(object)
}
return new object
Nice! With this, we can already new up classes. And the best thing is, they are fakable because ioc.use
first looks in the fake container that we can fill with ioc.fake
.
With that out of the way, let's build the automatic injection mechanism. The test:
it('should auto inject classes found in static inject', function() {
const injectsSimpleClass = ioc.make('test/modules/InjectsSimpleClass')
expect( injectsSimpleClass.simpleClass ).to.be.instanceOf( ioc.use('test/modules/SimpleClass') )
})
And we have to create the class InjectsSimpleClass.js
// test/modules/InjectsSimpleClass.js
class InjectsSimpleClass {
static get inject() {
return ['test/modules/SimpleClass']
}
constructor(simpleClass) {
this.simpleClass = simpleClass
}
}
module.exports = InjectsSimpleClass
The idea is that we statically define all the classes that need to be injected. These will be resolved by the ioc container and newed up as well.
ioc.make
will become:
if (typeof object === 'string') {
object = this.use(object)
}
// if the object does not have a static inject property, let's just new up the class
if (!Array.isArray(object.inject)) {
return new object
}
// resolve everything that needs to be injected
const dependencies = object.inject.map(path => {
const classDeclaration = this.use(path)
return new classDeclaration
})
return new object(...dependencies)
Not bad. But something about return new classDeclaration
seems wrong... What if this injected class also has dependencies to resolve? This sounds like a classic case for recursion! Let's try it out with a new test.
it('should auto inject recursively', function() {
const recursiveInjection = ioc.make('test/modules/RecursiveInjection')
expect(recursiveInjection.injectsSimpleClass.simpleClass).to.be.instanceOf(
ioc.use('test/modules/SimpleClass')
)
})
And we have to create a new file to help us with the test.
// test/modules/RecursiveInjection.js
class RecursiveInjection {
static get inject() {
return ['test/modules/InjectsSimpleClass']
}
constructor(injectsSimpleClass) {
this.injectsSimpleClass = injectsSimpleClass
}
}
module.exports = RecursiveInjection
The test will currently fail saying AssertionError: expected undefined to be an instance of SimpleClass
. All we have to do is switch out
const dependencies = object.inject.map(path => {
const classDeclaration = this.use(path)
return new classDeclaration
})
with
const dependencies = object.inject.map(path => this.make(path))
Altogether, the make
method looks like this
if (typeof object === 'string') {
object = this.use(object)
}
// if the object does not have a static inject property, let's just new up the class
if (!Array.isArray(object.inject)) {
return new object
}
// resolve everything that needs to be injected
const dependencies = object.inject.map(path => this.make(path))
return new object(...dependencies)
And that's pretty much it! The version in the repo handles some more things like not newing up non-classes, being able to pass additional arguments, aliasing etc. But this should cover the basics of automatic injection. It's surprising how little code is necessary to achieve this.
Top comments (0)