Demystifying Dependency Injection, Inversion of Control, Service Containers and Service Providers

Published 5/19/2019

This article is part of a series:


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".

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

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.