DEV Community

Michael Z
Michael Z

Posted on • Updated on • Originally published at michaelzanggl.com

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

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And the test could look something like this


class TestableDatabase {
    insert() {
        return true
    }
}


const db = new TestableDatabase
const userService = new UserService(db)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Now you can use ioc.use at any point in your app to access the userService.

ioc.use('userService').create({ id: 1})
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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()
    },
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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()
    },
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

How about we add this to the service container instead?

// providers/AppProvider.js

ioc.bind('API/GitHub', () => require('../app/apis/GitHub')
Enter fullscreen mode Exit fullscreen mode

and now we can use it like this from anywhere

ioc.use('API/GitHub')
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can access the GitHub service using

ioc.use('apis/GitHub')
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo MZanggl / ioc-node

Inversion of Control container for Node

Zero dependency IOC container for Node

Installation

npm install ioc-node

Instantiate

// index.js
global.ioc = require('ioc-node')(__dirname)
Enter fullscreen mode Exit fullscreen mode

Usage

Imagine the following class

class UserService {
    constructor(database) {
        this.database = database
    }

    create(data) {
       this.database.create('user', data)
    }
}
Enter fullscreen mode Exit fullscreen mode

You can inject dependencies using

ioc.bind('userService', () => new UserService(new Database))
Enter fullscreen mode Exit fullscreen mode

and later make use of the binding with

ioc.use('userService').create({ id: 1})
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

If this article helped you, I have a lot more tips on simplifying writing software here.

Top comments (2)

Collapse
 
juliang profile image
Julian Garamendy

This is a very good article. It does a great job at explaining a service locator implementation.

However, I'm of the following opinion:

"the problem with Service Locator is that it hides a class' dependencies, causing run-time errors instead of compile-time errors, as well as making the code more difficult to maintain because it becomes unclear when you would be introducing a breaking change."

blog.ploeh.dk/2010/02/03/ServiceLo...

Collapse
 
santypk4 profile image
Sam

Great article ! 🙌