IoC in regular JavaScript (ES6+)

Publication date: 2023-07-17

In this post, I will demonstrate the practical application of the IoC concept in JavaScript code. Unlike languages like PHP, Java, and TypeScript, JavaScript does not have abstractions (interfaces). Therefore, the principle of dependency inversion (“concrete implementations should depend on abstractions”) is difficult for a JavaScript developer to grasp. I hope that by practically applying the inversion of control in JavaScript, I can help clarify this issue specifically for JavaScript developers.

Caution: Everything said in this post refers to the ES6+ version, where the project source code is placed in ES modules and linked together using import/export.

Importing as a dependency

First, let’s define what a “dependency” is in ES6+, particularly when considering the situation at the module level. I dare say that any import (static or dynamic) represents a dependency of our module on some other module. Thus, in the following code, we can safely say that the service module has at least one dependency — on the logger module:

import logger from ‘./logger.js’; export class Service {…}

The forward control

Usually, a service developer needs to know where the dependencies for his code are. He can follow the link in the import and see the imported objects (dependencies) — observe the specific implementation of the dependencies:

export default {

error: (msg) => console.error(msg),

info: (msg) => console.info(msg),

};

Imported dependencies can be used inside a module:

import logger from ‘./logger.js’; export class Service {
exec(opts) {
logger.info(Service is running.);
}
}

When we use a service elsewhere:

import {Service} from ‘./service.js’; const s = new Service();
s.exec({});

we also import its dependencies one by one.

With this approach, all project modules end up being rigidly linked with imports into a single hierarchy at the stage of writing the code.

The inverted control

Let’s imagine a situation where a project can have several loggers: console, file, network, database, etc. It is possible that one developer creates a service that uses logging, while another developer creates a file logger, and a third one makes a network logger. The first thing all developers will need to do is agree on a logger interface, for example, like this:

class ILogger {
error(msg) {} info(msg) {}
}

This code will never be executed by a computer. It uses JSDoc solely to document the development process and to harmonize the interaction between different developers. The file logger developer writes something like:

class LoggerFile {
error(msg) { } info(msg) { }
}

The network logger developer writes something like:

class LoggerNet {
error(msg) { } info(msg) { }
}

The service developer does not know which logger will be used at runtime and must provide the ability to add the required dependency (the classic way — in the constructor):

export class Service {
logger; constructor(logger) {
this.logger = logger;
}
exec(opts) {
this.logger.info(Service is running.);
}
}

This is where the inversion of control comes in. Now, it is not the service itself that decides where it gets the required dependency, but some external code to the service (usually called the object container). We do not hard-link the project code through imports. Dependencies can be injected at runtime, flexibly, according to conditions.

Conclusion

In principle, in a project with dependency injection, almost the entire project can be written without using imports at all. Imports will be needed only in the composition root (the object container), which will collect all the code together, depending on certain conditions.

The most important condition is that the code of the modules must provide the ability to inject the necessary dependencies into them from the outside (through the constructor or the setters). And what is nice with this approach is that the code becomes loosely coupled. You can drag and drop a module from one project to another, as long as the required dependencies are satisfied in the other project as well.

A vivid example of reducing connectivity is the possibility of creating a test environment for a service using TDD:

import assert from ‘assert’;
import {describe, it} from ‘mocha’;
import {Service} from ‘./service.js’; const logger = {
error(mg) {},
info(msg) {}
};
describe(‘Service’, () => {
it(‘does the job’, () => {
const service = new Service(logger);
service.exec({});
assert(true);
});
});

In the test, we mock the dependency of our service and define the behavior we need without referencing a specific implementation (such as file or network).

In general, inversion of control in application architecture is primarily about how the individual “bricks” for the entire “building” are created, and only then how they are put together. If the “bricks” are made correctly, they can be laid in many ways.

Image 2

I will tell you how to stack “bricks” with each other in the following posts. The purpose of this post is to give an understanding that ES6 modules without static imports may well serve as building blocks for an application. At the same time, it becomes unimportant what kind of application it is — browser or Node.js.

If you enjoyed this article, please give it a clap and follow me for more content!

Stay connected:

Thank you for your support!