Originally posted at michaelzanggl.com. Subscribe to my newsletter to never miss out on new content.
This article is meant to demystify those scary terms DI and IoC. We are going to code this in a node environment.
Imagine having the following code
// index.js
class Database {
insert(table, attributes) {
// inserts record in database
// ...
const isSuccessful = true
return isSuccessful
}
}
class UserService {
create(user) {
// do a lot of validation etc.
// ...
const db = new Database
return db.insert('users', user)
}
}
const userService = new UserService
const result = userService.create({ id: 1})
console.log(result)
Running node index.js
should now log the value "true".
What is happening in the code? There is a Database class used to save things into the database and a UserService class used to create users. The users are going to be saved in the database, so when we create a new user, we new up an instance of Database. In other words, UserService is dependent on Database. Or, Database is a dependency of UserService.
And here comes the problem. What if we were to write tests to check the part // do a lot of validation etc.
. We need to write a total of 10 tests for various scenarios. In all of these tests, do we really want to insert users into the database? I don't think so. We don't even care about this part of the code. So it would be nice if it was possible to swap out the database with a fake one when running tests.
Dependency Injection
Enter dependency injection. It sounds very fancy, but in reality is super simple. Rather than newing up the Database instance inside the "create" method, we inject it into the UserService like this.
class Database {
insert(table, attributes) {
// inserts record in database
const isSuccessful = true
return isSuccessful
}
}
class UserService {
constructor(db) {
this.db = db
}
create(user) {
return this.db.insert('users', user)
}
}
const db = new Database
const userService = new UserService(db)
const result = userService.create({ id: 1})
console.log(result)
And the test could look something like this
class TestableDatabase {
insert() {
return true
}
}
const db = new TestableDatabase
const userService = new UserService(db)
But of course, I hear what you saying. While we made the code testable, the API suffered from it. It's annoying to always pass in an instance of Database.
Inversion of Control
Enter Inversion of Control. Its job is to resolve dependencies for you.
It looks like this: At the start of the app you bind the instantiation to a key and use that later at any point.
Before we check out the code of our IoC container (also called service container), let's look at the usage first.
ioc.bind('userService', () => new UserService(new Database))
Now you can use ioc.use at any point in your app to access the userService.
ioc.use('userService').create({ id: 1})
Whenever you call ioc.use('userService')
, it will create a new instance of UserService, basically executing the callback of the second function. If you prefer to always access the same instance, use app.singleton instead of app.bind.
ioc.singleton('userService', () => new UserService(new Database))
ioc.use('userService').create({ id: 1})
Implementation of ioc
global.ioc = {
container: new Map,
bind(key, callback) {
this.container.set(key, {callback, singleton: false})
},
singleton(key, callback) {
this.container.set(key, {callback, singleton: true})
},
use(key) {
const item = this.container.get(key)
if (!item) {
throw new Error('item not in ioc container')
}
if (item.singleton && !item.instance) {
item.instance = item.callback()
}
return item.singleton ? item.instance : item.callback()
},
}
That's not a lot of code at all!
so the methods bind
and singleton
just store the key and callback inside a map and with the use
method, we get what we want from the container again.
We also make ioc
a global variable so it is accessible from anywhere.
But where do we put all those ioc bindings?
Service Providers
Enter the service provider. Another fancy term simply meaning "This is where we bind our stuff in the service container". This can be as simple as having
// providers/AppProvider.js
function register() {
ioc.singleton('userService', () => new UserService(new Database))
}
module.exports = { register }
The register method of the provider is then simply executed at the start of your app.
Testing
How do we test it now?
Well, in our test we can simply override the userService in the service container.
class TestableDatabase {
create() {
return true
}
}
ioc.singleton('userService', () => new UserService(new TestableDatabase))
ioc.use('userService').create({id: 1})
This works, but there is the problem that if you have tests that require the actual database in the userService, these might also receive the TeastableDatabase now. Let's create a fake
and restore
method on the ioc object instead. We also have to alter our use
method a little
global.ioc = {
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)
if (!item) {
throw new Error('item not in ioc container')
}
this.fakes.set(key, {callback, singleton: item.singleton})
},
restore(key) {
this.fakes.delete(key)
},
use(key) {
let item = this.container.get(key)
if (!item) {
throw new Error('item not in ioc container')
}
if (this.fakes.has(key)) {
item = this.fakes.get(key)
}
if (item.singleton && !item.instance) {
item.instance = item.callback()
}
return item.singleton ? item.instance : item.callback()
},
}
And let's update our test
class TestableDatabase {
insert() {
return true
}
}
ioc.fake('userService', () => new UserService(new TestableDatabase))
ioc.use('userService').create({id: 1})
ioc.restore('userService')
Other use cases
Avoids useless abstractions
This example is taken from the Adonis documentation.
Some objects you want to instantiate one time and then use repeatedly. You usually do this by having a separate file just to handle the singleton.
const knex = require('knex')
const connection = knex({
client: 'mysql',
connection: {}
})
module.exports = connection
With the IoC container this abstraction is not necessary, thus making the code base cleaner.
Avoids relative require
Imagine you are somewhere very deep inside the file app/controllers/auth/UserController.js
and want to require the file app/apis/GitHub.js
. How do you do that normally?
const GitHub = require('../../apis/GitHub')
How about we add this to the service container instead?
// providers/AppProvider.js
ioc.bind('API/GitHub', () => require('../app/apis/GitHub')
and now we can use it like this from anywhere
ioc.use('API/GitHub')
Since it is annoying to do that for every file, let's simply add a method to require files from the root directory.
Add the following code to the end of the ioc.use
method and remove the exception throw when the key was not found.
global.ioc = {
// ...
use(key) {
// ...
return require(path.join(rootPath, namespace))
}
}
Now we can access the GitHub service using
ioc.use('apis/GitHub')
But with that the ioc container must live in the root of the directory. Let's extract the IoC container out and make a factory out of it. The end result is
//lib/ioc.js
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)
if (!item) {
throw new Error('item not in ioc container')
}
this.fakes.set(key, {callback, singleton: item.singleton})
},
restore(key) {
this.fakes.delete(key)
},
use(namespace) {
let item = this.container.get(namespace)
if (item) {
if (this.fakes.has(namespace)) {
item = this.fakes.get(namespace)
}
if (item.singleton && !item.instance) {
item.instance = item.callback()
}
return item.singleton ? item.instance : item.callback()
}
return require(path.join(rootPath, namespace))
}
}
}
We wrapped the object inside the function createIoC
that expects the root path to be passed in. The "require" method now returns the following return require(rootPath + '/' + path)
.
And inside index.js
we now have to create the container like this
global.ioc = require('./lib/ioc')(__dirname)
And that's it for the basics of IoC! I put the code on GitHub where you can check it out again. I also added some tests to it and made it possible to fake root requires as well.
Zero dependency IOC container for Node
Installation
npm install ioc-node
Instantiate
// index.js
global.ioc = require('ioc-node')(__dirname)
Usage
Imagine the following class
class UserService {
constructor(database) {
this.database = database
}
create(data) {
this.database.create('user', data)
}
}
You can inject dependencies using
ioc.bind('userService', () => new UserService(new Database))
and later make use of the binding with
ioc.use('userService').create({ id: 1})
If you don't want to create a new instance every time you use ioc.use
, create the binding with ioc.singleton
instead of ioc.bind
.
ioc.singleton('userService', () => new UserService(new Database))
ioc.use('userService')
…If this article helped you, I have a lot more tips on simplifying writing software here.
Top comments (2)
This is a very good article. It does a great job at explaining a service locator implementation.
However, I'm of the following opinion:
blog.ploeh.dk/2010/02/03/ServiceLo...
Great article ! 🙌