Dependency Injection Container in JavaScript

Christopher T.

April 7th, 2020

Share This Post

JavaScript is capable of many techniques due to its nature in flexibility. In this post, we will be going over the Dependency Injection Container.

This pattern actually provides the same goal as the Dependency Injection, but in a more flexible and powerful way by acting as the container that houses dependencies of functions (or classes) that require them when times they need it, such as during their initialization phase.

Dependency Injection Without The Container

Let's quickly refresh our minds on what Dependency Injection is, how it looks like in code, what problems it solves, and what problems it suffers from.

The Dependency Injection is a pattern that helps to avoid hard coding dependencies in modules, giving the caller the power to change them and provide their own if they wanted to in one place.

These dependencies can be injected into the constructor (instantiation) phase or can be set later by some setter method:

class Frog {
  constructor(name, gender) {
    this.name = name
    this.gender = gender
  }

  jump() {
    console.log('jumped')
  }
}

class Toad {
  constructor(habitat, name, gender) {
    this.habitat = habitat
    this.frog = new Frog(name, gender)
  }
}

const mikeTheToad = new Toad('land', 'mike', 'male')

There are some issues with this:

Issue #1: If we needed to change how Toad was constructed and it required something fragile like the positioning of arguments or the data structure of them, we would have to manually change the code since it is hardcoded into their block of code.

An example of this scenario is when there is a breaking change in the Frog class.

For one, if Frog added a third parameter in its constructor like weight:

class Frog {
  constructor(name, gender, weight) {
    this.name = name
    this.gender = gender
    this.weight = weight
  }

  jump() {
    console.log('jumped')
  }
}

Then our Toad must be updated because this new dependency was added into our Frog instantiation:

class Toad {
  constructor(habitat, name, gender, weight) {
    this.habitat = habitat
    this.frog = new Frog(name, gender, weight)
  }
}

So if we kept it this way, how many times do you think you would end up having to change Toad if you were in some frog startup company and that was one of the first pieces of code you started with?

Issue #2: You have to know what dependency to use for Toad everytime.

We have to know that Toad now needs 4 arguments in the exact same order for it to initiate an instance of Frog correctly, even their data types otherwise bugs can easily occur.

And it can seem quite awkward if you know that a Toad is essentially a frog, so knowing that, you might accidentally assume that Toad would be extending Frog then. So then you realize that an instance of Frog is being created inside Toad instead, and now you get all confused because you're an intelligent human being and the code was just throwing you off--realizing that the code does not align properly with the real world.

Issue #3: Unnecessarily involves more code

With the Dependency Injection pattern, these problems are solved by inversing the control of the way the dependencies are instantiated:

class Frog {
  constructor({ name, gender, weight }) {
    this.name = name
    this.gender = gender
    this.weight = weight
  }

  jump() {
    console.log('jumped')
  }
}

class Toad {
  constructor(habitat, frog) {
    this.habitat = habitat
    this.frog = frog
  }
}

Okay, that was easily. Now when there's another breaking change to Frog (such as the arguments being put into a JavaScript object), we don't even have to touch Toad or waste brain cells reading Toad, then Frog, then back to Toad, etc.

That's because we can now just change the part where we create an instance of Toad (which is better than having to go inside and change stuff in the Toad implementation--which is bad practice! It shouldn't have to worry about how frog is constructed--it should only know that it takes a frog as an argument and stores it in its .frog property to use later. You take charge in its dependencies now.

const mikeTheToad = new Toad(
  'land',
  new Frog({
    name: 'mike',
    gender: 'male',
    weight: 12.5,
  }),
)

So, we just practiced some clean code practices by abstracting out implementation details of Frog away from the Toad constructor. It makes sense: does Toad even have to care about how Frog is constructed? If anything, it should have just extended it!

Dependency Injection Container (DIC) Pattern

Now that we've refreshed our minds on Dependency Injection, let's talk about the Dependency Injection Container!

So why do we need the DIC pattern and why isn't the Dependency Injection without the container enough in tough situations?

Here's the problem: It's simply just not scalable. The larger your project becomes the more you start losing confidence in maintaining your code in the long run because then it just becomes a mess over time. In addition, you also have to get the order of injecting dependencies in the correct order so that you don't fall into the issue of something being undefined when you're instantiating something.

So in essence, 6 months later our code can evolve to something like this:

class Frog {
  constructor({ name, gender, weight }) {
    this.name = name
    this.gender = gender
    this.weight = weight
  }

  jump() {
    console.log('jumped')
  }

  setHabitat(habitat) {
    this.habitat = habitat
  }
}

class Toad extends Frog {
  constructor(options) {
    super(options)
  }

  leap() {
    console.log('leaped')
  }
}

class Person {
  constructor() {
    this.id = createId()
  }
  setName(name) {
    this.name = name
    return this
  }
  setGender(gender) {
    this.gender = gender
    return this
  }
  setAge(age) {
    this.age = age
    return this
  }
}

function createId() {
  var idStrLen = 32
  var idStr = (Math.floor(Math.random() * 25) + 10).toString(36) + '_'
  idStr += new Date().getTime().toString(36) + '_'
  do {
    idStr += Math.floor(Math.random() * 35).toString(36)
  } while (idStr.length < idStrLen)

  return idStr
}

class FrogAdoptionFacility {
  constructor(name, description, location) {
    this.name = name
    this.description = description
    this.location = location
    this.contracts = {}
    this.adoptions = {}
  }

  createContract(employee, client) {
    const contractId = createId()
    this.contracts[contractId] = {
      id: contractId,
      preparer: employee,
      client,
      signed: false,
    }
    return this.contracts[contractId]
  }

  signContract(id, signee) {
    this.contracts[id].signed = true
  }

  setAdoption(frogOwner, frogOwnerLicense, frog, contract) {
    const adoption = {
      [frogOwner.id]: {
        owner: {
          firstName: frogOwner.owner.name.split(' ')[0],
          lastName: frogOwner.owner.name.split(' ')[1],
          id: frogOwner.id,
        },
        frog,
        contract,
        license: {
          id: frogOwnerLicense.id,
        },
      },
    }
    this.adoptions[contract.id] = adoption
  }

  getAdoption(id) {
    return this.adoptions[id]
  }
}

class FrogParadiseLicense {
  constructor(frogOwner, licensePreparer, frog, location) {
    this.id = createId()
    this.client = {
      firstName: frogOwner.name.split(' ')[0],
      lastName: frogOwner.name.split(' ')[1],
      id: frogOwner.id,
    }
    this.preparer = {
      firstName: licensePreparer.name.split(' ')[0],
      lastName: licensePreparer.name.split(' ')[1],
      id: licensePreparer.id,
    }
    this.frog = frog
    this.location = `${location.street} ${location.city} ${location.state} ${location.zip}`
  }
}

class FrogParadiseOwner {
  constructor(frogOwner, frogOwnerLicense, frog) {
    this.id = createId()
    this.owner = {
      id: frogOwner.id,
      firstName: frogOwner.name.split(' ')[0],
      lastName: frogOwner.name.split(' ')[1],
    }
    this.license = frogOwnerLicense
    this.frog = frog
  }

  createDocument() {
    return JSON.stringify(this, null, 2)
  }
}

We got a pretty nice app--a frog adoption facility where customers can come and adopt a frog. But the adoption process is not a simple give / receive money transaction. We're pretending that there's a law requiring this process to be conducted for every frog adoption facility being handing frogs to their new owners.

The whole adoption process ends when setAdoption from FrogAdoptionFacility is called.

Let's pretend you start developing code using these classes and ended up with a working version like so:

const facilityTitle = 'Frog Paradise'
const facilityDescription =
  'Your new one-stop location for fresh frogs from the sea! ' +
  'Our frogs are housed with great care from the best professionals all over the world. ' +
  'Our frogs make great companionship from a wide variety of age groups, from toddlers to ' +
  'senior adults! What are you waiting for? ' +
  'Buy a frog today and begin an unforgettable adventure with a companion you dreamed for!'
const facilityLocation = {
  address: '1104 Bodger St',
  suite: '#203',
  state: 'NY',
  country: 'USA',
  zip: 92804,
}

const frogParadise = new FrogAdoptionFacility(
  facilityTitle,
  facilityDescription,
  facilityLocation,
)

const mikeTheToad = new Toad({
  name: 'mike',
  gender: 'male',
  weight: 12.5,
})

const sally = new Person()
sally
  .setName('sally tran')
  .setGender('female')
  .setAge(27)

const richardTheEmployee = new Person()
richardTheEmployee
  .setName('richard rodriguez')
  .setGender('male')
  .setAge(77)

const contract = frogParadise.createContract(richardTheEmployee, sally)

frogParadise.signContract(contract.id, sally)

const sallysLicense = new FrogParadiseLicense(
  sally,
  richardTheEmployee,
  mikeTheToad,
  facilityLocation,
)

const sallyAsPetOwner = new FrogParadiseOwner(sally, sallysLicense, mikeTheToad)

frogParadise.setAdoption(sallyAsPetOwner, sallysLicense, mikeTheToad, contract)

const adoption = frogParadise.getAdoption(contract.id)
console.log(JSON.stringify(adoption, null, 2))

If we run the code, it will work and create us a new adoption object that looks like this:

{
  "t_k8pgj8gh_k4ofadkj2x4yluemfgvmm": {
    "owner": {
      "firstName": "sally",
      "lastName": "tran",
      "id": "t_k8pgj8gh_k4ofadkj2x4yluemfgvmm"
    },
    "frog": {
      "name": "mike",
      "gender": "male",
      "weight": 12.5
    },
    "contract": {
      "id": "m_k8pgj8gh_kdfr55oui28c88lisswak",
      "preparer": {
        "id": "n_k8pgj8gh_uxlbmbflwjrj4cqgjyvyw",
        "name": "richard rodriguez",
        "gender": "male",
        "age": 77
      },
      "client": {
        "id": "h_k8pgj8gh_hkqvp4f3uids8uj00i47d",
        "name": "sally tran",
        "gender": "female",
        "age": 27
      },
      "signed": true
    },
    "license": {
      "id": "y_k8pgj8gh_0qnwm9po0cj7p3vgsedu3"
    }
  }
}

We got a pretty nice app--a frog adoption facility where customers can come and adopt a frog. But the adoption process is not a simple give / receive money transaction. We're pretending that there's a law requiring this process to be conducted for every frog adoption facility being handing frogs to their new owners.

So it requires the facility (Frog Paradise) to generate a contract that requires the customer's signature. Then, a license is also created on the spot that the customer needs to have on them for legal protection. And finally, the adoption is completed after all is done.

Take a look at the FrogOwner class:

class FrogParadiseOwner {
  constructor(frogOwner, frogOwnerLicense, frog) {
    this.id = createId()
    this.owner = frogOwner
    this.license = frogOwnerLicense
    this.frog = frog
  }

  createDocument() {
    return JSON.stringify(this, null, 2)
  }
}

It has three dependencies: frogOwner, frogOwnerLicense, andfrog.

Lets pretend there was an update with frogOwner (an instance of Person) and it changed to become an instance of Client:

class Client extends Person {
  setName(name) {
    this.name = name
  }
}

Now calls to initializing FrogParadiseOwner need to be updated.

But what if we had initialized FrogParadiseOwner throughout several locations of our code? If our code gets longer and the number of these instances increase, the more it becomes an issue to maintain.

This is where the Dependency Injection Container can make the difference, because you would only need to change your code in one location.

This is what a dependency injection container can look like:

import parseFunction from 'parse-function'

const app = parseFunction({
  ecmaVersion: 2017,
})

class DIC {
  constructor() {
    this.dependencies = {}
    this.factories = {}
  }

  register(name, dependency) {
    this.dependencies[name] = dependency
  }

  factory(name, factory) {
    this.factories[name] = factory
  }

  get(name) {
    if (!this.dependencies[name]) {
      const factory = this.factories[name]
      if (factory) {
        this.dependencies[name] = this.inject(factory)
      } else {
        throw new Error('No module found for: ' + name)
      }
    }
    return this.dependencies[name]
  }

  inject(factory) {
    const fnArgs = app.parse(factory).args.map((arg) => this.get(arg))
    return new factory(...fnArgs)
  }
}

With this in place, it becomes as easy as this to update changes:

class Client extends Person {
  setName(name) {
    this.name = name
  }
}

const dic = new DIC()
dic.register('frogOwner', Client)
dic.register('frogOwnerLicense', sallysLicense)
dic.register('frog', mikeTheToad)

dic.factory('frog-owner', FrogParadiseOwner)
const frogOwner = dic.get('frog-owner')

Now instead of directly initializing it like before and having to change all other instances of the code:

const frogOwner = new FrogParadiseOwner(Client, sallysLicense, mikeTheToad)
// some other location
const frogOwner2 = new FrogParadiseOwner(...)
// some other location
const frogOwner3 = new FrogParadiseOwner(...)
// some other location
const frogOwner4 = new FrogParadiseOwner(...)
// some other location
const frogOwner5 = new FrogParadiseOwner(...)

You can instead use the DIC to update it once and you won't need to change any other parts of your code, because we reversed the direction of the flow for that to the container:

// Update here only by passing the dependency to the DIC
const dic = new DIC()
dic.register('frogOwner', Client)
dic.register('frogOwnerLicense', sallysLicense)
dic.register('frog', mikeTheToad)
dic.factory('frog-owner', FrogParadiseOwner)

const frogOwner = dic.get('frog-owner')

Let's explain what the DIC is doing:

You insert any classes or functions you want to be resolved by the DIC by passing it into the .factory() method which gets stored into the .factory property.

dependency-injection-container-factory-method-in-javascript

For each of those functions passed into .factory you would have to register their arguments using .register() so that they can be picked up when the container is initializing the requested function. They get picked up from the .dependencies property. You can add things to the dependencies using the .dependencies() method.

dependency-injection-container-dependencies-in-javascript

When you want to retrieve something, you use .get with some key. It uses the key to look through its dependencies and if it finds something there it will return it. Otherwise, it will proceed to look through its factories and if it finds something it will treat it as a function that you want it to resolve.

dependency-injection-container-get-method-in-javascript

Then it passes the invocation to .inject in which it reads the names of the function's dependencies (arguments) and grabs them from its .dependencies property, invoking the function and injecting its arguments, returning the result.

dependency-injection-container-inject-in-javascript)

In our code examples I used parse-function to allow the inject method to grab the namees a function's arguments.

To do it without the library, you can add an extra argument to .get and have it pass down to its .inject like this:

class DIC {
  constructor() {
    this.dependencies = {}
    this.factories = {}
  }

  register(name, dependency) {
    this.dependencies[name] = dependency
  }

  factory(name, factory) {
    this.factories[name] = factory
  }

  get(name, args) {
    if (!this.dependencies[name]) {
      const factory = this.factories[name]
      if (factory) {
        this.dependencies[name] = this.inject(factory, args)
      } else {
        throw new Error('No module found for: ' + name)
      }
    }
    return this.dependencies[name]
  }

  inject(factory, args = []) {
    const fnArgs = args.map((arg) => this.get(arg))
    return new factory(...fnArgs)
  }
}

Nonetheless we still get the same result:

const dic = new DIC()
dic.register('frogOwner', Client)
dic.register('frogOwnerLicense', sallysLicense)
dic.register('frog', mikeTheToad)

dic.factory('frog-owner', FrogParadiseOwner)
const frogOwner = dic.get('frog-owner', [
  'frogOwner',
  'frogOwnerLicense',
  'frog',
])
console.log('frog-owner', JSON.stringify(frogOwner, null, 2))

Result:

{
  "id": "u_k8q16rjx_fgrw6b0yb528unp3trokb",
  "license": {
    "id": "m_k8q16rjk_jipoch164dsbpnwi23xin",
    "client": {
      "firstName": "sally",
      "lastName": "tran",
      "id": "b_k8q16rjk_0xfqodlst2wqh0pxcl91j"
    },
    "preparer": {
      "firstName": "richard",
      "lastName": "rodriguez",
      "id": "g_k8q16rjk_f13fbvga6j2bjfmriir63"
    },
    "frog": {
      "name": "mike",
      "gender": "male",
      "weight": 12.5
    },
    "location": "undefined undefined NY 92804"
  },
  "frog": {
    "name": "mike",
    "gender": "male",
    "weight": 12.5
  }
}

Tags


Subscribe to the Newsletter
Get continuous updates
© jsmanifest 2020