Demystifying Dependency Injection: A Simple Object Container for modern JS
Publication date: 2023-07-31In my previous post, I described a straightforward example to illustrate the concept of Inversion of Control in JavaScript code. By avoiding static imports and, instead, adding dependencies through a factory function or constructor, code coupling is reduced, leading to improved code maintenance and refactoring in large projects. Code composed of such “bricks” (ES6 modules) can be employed in more complex “buildings” (applications) because the code’s dependencies on specific details (static imports) are substituted with dependencies on abstractions (dependency identifiers).
IMPORTANT: All of this applies to regular JavaScript, not TypeScript. TypeScript utilizes transpilation to convert into JavaScript, so dependency injection is commonly performed in a different manner (via annotations). The examples in this post do not involve transpilation.
Here, I will show you step by step how to build your object container that can download the source code of ES6 modules, create necessary dependencies, and insert them in the correct locations. I must immediately warn you that, in order to simplify the presentation in the demo code, certain assumptions related to the object generation will be made. The purpose of this article is to demonstrate the actual technology of dependency injection, rather than providing a ready-made “one-size-fits-all” solution. As a result, you should gain an understanding of how to create your object container in modern JS if you ever find the need for it (the final object container in the demo contains about 35 lines of code).
I have divided the entire post into 6 parts to gradually enhance the functionality of the demo code, leading to the creation of an actual object container:
- The Composition Root
- The Factory
- The Specification of Dependencies
- The Spec Parser
- The Object Container
- The Resolver
1. The Composition Root
In regular ES6+ code, static imports are used to load sources and create dependencies. It is a “direct control”:
import logger from ‘./logger.js’; export default class Service {
exec(opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
}
}
Here is an example of inversion, where control over the creation of dependencies is given to an external agent, and the service only provides the opportunity to inject dependencies into it:
export default class Service {
constructor(logger) {
this.exec = function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
}
}
If the service itself does not create dependencies, then there must be a place somewhere in the application where these dependencies are created. This place is called the Composition Root:
import logger from ‘./logger.js’;
import Service from ‘./service.js’; const serv = new Service(logger);
serv.exec({name: ‘The Composition Root’});
Any code that uses inversion of control, including test units, has a place where the source code of dependencies is loaded and the desired objects are created.
2. The Factory
Classes are syntactic sugar, and object creation can be done with normal functions (factories):
async function Factory(dep1, dep2, …) {
return ;
}
For simplicity, let’s assume that each ES6 module exports, by default, such an asynchronous factory that takes a dependencies as input arguments and produces the resulting object as output:
export default async function Factory(logger) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
}
In this case, our composition root could look like this:
import fLogger from ‘./logger.js’;
import fService from ‘./service.js’; const logger = await fLogger();
const serv = await fService(logger);
serv({name: ‘The Factory’});
Our simplification just makes the demo code much easier. In the general case, the export can be anything — a class, a function, an object.
3. The Specification of Dependencies
Ordinary function arguments in JavaScript can be renamed during code minification:
function Factory(logger, config) {}
After minification:
function Factory(a, b) {}
However, if we adopt the practice of passing all the necessary dependencies into the constructor in the form of a single object — the specification:
function Factory(spec) {} where each property of the specification represents a separate dependency:
function Factory({logger, config}) {} then we protect ourselves from potential changes in the names of dependencies and gain the opportunity to analyze the names.
In JavaScript, the key in an object can be any string:
const obj = {[’any string with spec. chars: !@#$%^&*()_+’]: prop};
When creating a service, we can put the path to the source of the dependency in the specification itself:
export default async function Factory({[‘./logger.js’]: logger}) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
}
The composition root for this case:
import fLogger from ‘./logger.js’;
import fService from ‘./service.js’; const logger = await fLogger();
const serv = await fService({[‘./logger.js’]: logger});
serv({name: ‘The Spec’});
It might seem that our code has become more confusing. Instead of using static imports, we now specify source paths in the dependency specification of the factory function and in the composition root. But be patient a little, and you will see what happens in the end.
4. The Spec Parser
A typical factory function now looks like this:
function Factory(
{
}
) { }
We can transform a factory function to the string and get paths to the dependencies:
function parser (def) {
const res = [];
const parts = /function Factory({(.)})./s.exec(def);
if (parts?.[1]) {
const deps = parts[1].split(‘,’);
for (const dep of deps) {
const left = dep.split(‘:’)[0];
const path = left.trim()
.replace(/‘/g,’’)
.replace(/“/g, ’’)
.replace(‘[’, ’’)
.replace(‘]’, ’’);
res.push(path); } } return res; }; const paths = parser(factory.toString());
5. The Object Container
At this point, we have an agreement on the format for specifying dependencies and how they are created (factories). We can recursively load our modules and their dependencies:
const deps = {}; const FN = /function Factory({(.)})./s;
function parser(def) {
const res = [];
const parts = FN.exec(def);
if (parts?.[1]) {
const deps = parts[1].split(‘,’);
for (const dep of deps) {
const left = dep.split(‘:’)[0];
const path = left.trim()
.replace(/‘/g,’’)
.replace(/“/g, ’’)
.replace(‘[’, ’’)
.replace(‘]’, ’’);
res.push(path);
}
}
return res;
}
async function get(key) {
if (deps[key]) return deps[key];
else {
const {default: factory} = await import(key);
const def = factory.toString();
const paths = parser(def);
const spec = {};
for (const path of paths)
spec[path] = await get(path);
const res = factory(spec);
deps[key] = res;
return res;
}
}
export default {get};
The object container is the composition root now. We should use this container to run our app:
import container from ‘./container.js’; const serv = await container.get(‘./service.js’);
serv({name: ‘The Object Container’});
6. The Resolver
This is the most important part of the post. Up to this point, we were still under direct control. Objects still defined their dependencies, but previously, they did so through static imports:
import logger from ‘./logger.js’; export default function {
logger.info(Service is running with: ${JSON.stringify(opts)});
}
Now, we specify the path to the sources in the dependency specification:
export default async function Factory({[‘./logger.js’]: logger}) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
} In both cases, we use “details” (speaking in terms of the Dependency Inversion Principle). In this step, we remove the details from the dependency specification and leave only abstractions:
export default async function Factory({logger, config}) {
return function (opts) {
logger.info(Service '${config.appName}' is running with: ${JSON.stringify(opts)});
};
}
Now, for the container, we need a map according to which we can match the details of each abstraction. This map is defined in the main script:
import container from ‘./container.js’; const map = {
service: ‘./service.js’,
logger: ‘./logger.js’,
config: ‘./config.js’,
};
container.setMap(map);
const serv = await container.get(‘service’);
serv({name: ‘The Resolver’});
We no longer use early binding (static binding) based on the details in the code. Instead, we use abstractions in the specification (logger, config) and can determine at runtime which details (./log/console.js or ./log/file.js) correspond to which abstraction (late binding). This approach turns our code from a “welded structure” into a “bolted structure”. We can unscrew a few bolts, detach a part of the structure, and use it in another project.
If it seems to you that mapping for all the abstractions of a project is very time consuming, then you are right. Nobody does that, especially in big projects. Instead, the naming conventions used in the project are worked out, and the path to the sources is restored by the dependency identifier:
- @vendor/package/src/Mod
- com.vendor.package.Mod
- /Vendor/Package/Mod
- Vendor_Package_Mod
All of these identifiers, when applying the appropriate conversion rules, can point to the same ES6 module:
./node_modules/@vendor/package/src/Mod.js In PHP, there has been an autoload function for a very long time, which allows you to set the path to the source file by the name of the class being loaded.
Moreover, since this is late binding, additional runtime-related data can be added to the identifier. For example, the lifetime (singleton or transient):
/Vendor/Package/Mod:singleton You can even use URL-like style to address dependencies:
@vendor/package/path/module#export?singleton&scope=request&adapter=sock2 It all depends on the application’s requirements and the developers’ imagination.
Conclusion
Dependency inversion can greatly reduce code coupling and can be useful in applications with a large number of files and packages. Due to the nature of JavaScript, these are basically Node.js applications. At the front end, bundlers/packers are traditionally used, which create separate bundles in which code from different packages is assembled. Therefore, this technique is not well-suited for traditional web applications.
However, it can be used in PWAs (Progressive Web Apps) because it allows you to download sets of files with source codes through the Service Worker and store them in the browser cache. In this case, this approach allows you to create ES6 modules that will work the same way both on the back end and on the front end.
If you want to try using dependency injection in your project and have a questions, please contact me, I will try to answer your questions.
If you enjoyed this article, please give it a clap and follow me for more content!
Stay connected:
Thank you for your support!