Dependency Injection Containers in JavaScript
jsmanifest
Posted on April 11, 2020
Find me on medium
Join my newsletter
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.
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.
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.
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.
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)
}
}
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))
Nonetheless we still get the same 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
}
}
Find me on medium
Join my newsletter
Posted on April 11, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.