Inversion of control (IoC) and dependency injection (DI) are both terms to describe an architectural strategy that enables modules to interact indirectly. In IoC and DI, modules interact via public types or interfaces: the IoC and DI framework selects specific implementations.
Inversion of control is a general term describing the reversal of the normal flow of control in traditional application development. Under inversion of control, the framework invokes your code. This reversal is the opposite of the typical situation where your code invokes libraries or frameworks.
Dependency injection is one specific, but popular example, in which a framework or architecture handles object creation.
Problem
In the team shopping list application, the Item
class directly calls the expense, icon and notification services. The Item
class depends on the modules that implement the services. Thus, the expense, icon and notification modules are said to be dependencies of the Item
class.
Now, suppose that the system needs to support different implementations of those dependencies. For example, in some companies, email is the preferred notification method, other places prefer sending SMS, and perhaps some still use pagers. The development team may also require special implementations for testing: fake notification services that do not send real messages to end-users.
A naïve and highly inconvenient implementation would incorporate all of the possible implementations and constantly grow in complexity. For example, the following logic might start appearing in item.js
:
const emailNotificationService = require('./services/email_notification.js');
const smsNotificationService = require('./services/sms_notification.js');
const pagerNotificationService = require('./services/pager_notification.js');
const testingNotificationService = require('./services/testing_notification.js');
...
class Item {
constructor(description, quantity) {
...
switch (preferredMethod) {
case 'email':
emailNotificationService.notifyNewItem(description);
break;
case 'sms':
smsNotificationService.notifyNewItem(description);
break;
case 'pager':
pagerNotificationService.notifyNewItem(description);
break;
case 'testing':
testingNotificationService.notifyNewItem(description);
break;
}
...
}
...
}
...
As the implementation grows in complexity, this code will become unmanageable. The architectural difficulty lies in managing multiple implementations of dependencies and allowing those implementations to be easily ‘plugged in’ or ‘swapped’.
Solution
Dependency injection reverses the control over the management of dependencies. With dependency injection, a module such as item.js
does not directly import its dependencies. Instead, a dependency injection framework ‘injects’ dependencies into the module when it loads.
For example, the basic idea of this solution is that the item
module in item.js
should not use require('./services/email_notification.js')
to load a specific notification service. Instead, an external module will load the email notification service separately and then provide that pre-loaded service to the item
module.
Dependency injection separates dependency loading from the use of those dependencies in modules. This separation makes it easier to substitute the underlying module implementations. Also, it allows new capabilities to extend existing modules. For example, the dependency injection framework can automatically add logging capabilities.
Dependency injection and inversion of control are humorously referred to as an implementation of the “Hollywood Principle”. This principle refers to the cliché of directors in Hollywood telling amateur actors, “don’t call us, we’ll call you”. This request is a polite way to tell overly enthusiastic actors they aren’t wanted. It avoids the time wasted by actors eagerly calling to see if they got the acting role. In dependency injection, the idea is that a module (actor) is not responsible for identifying who to call. The dependency injection framework calls the module, providing any required dependencies.
Implementation
Implementation with function closures
A simple way to implement dependency injection is to remove a module’s require
statements with parameters. Instead, the module should export an initialization function that accepts dependencies.
For example, consider the following code:
const notificationService = require('./services/notification.js');
class Item {
// implementation using notificationService
}
module.exports = { Item };
A simple translation to use dependency injection would involve adding the notification service as a parameter to the module:
const create = (notificationService) => {
class Item {
// implementation using notificationService
}
return { Item }; // this is the old module.exports
}
module.exports = create;
This code allows a notification service to be ‘injected’ when loading the module. Other modules specify the dependencies during initialization before the first use.
For example, normally item.js
would be loaded in src/server/domain.js
using a simple require(…)
expression:
const { Item } = require('./item.js');
Instead, the additional parameters — the dependencies — would be supplied when the library is loaded:
const emailNotificationService = require('./services/email_notification.js');
const { Item } = require('./item.js')(emailNotificationService);
Implementation with lookup tables
A different way to implement dependency injection could be to declare a module variable:
let dependencies = {
notificationService: null;
}
class Item {
// implementation using dependencies.notificationService
}
module.exports = { Item, dependencies };
Then in a separate configuration or loading file (e.g., setup.js
), these dependencies can be configured to refer to a specific module:
// The concrete implementations for each "injectable" service
let implementations = {
notificationService: require('./services/email_notification.js'),
iconService: require('./services/jpeg_icon.js'),
expenseService: require('./services/testing_expense.js')
}
// The modules that need dependency injection
let injectionTargets = [
require('./item.js'),
require('./domain.js')
]
// Update the 'dependencies' of each module needing injection
for (let target of injectionTarget) {
if ('dependencies' in target) {
Object.assign(target, implementations);
}
}
Implementation with DI frameworks
Established dependency injection containers can offer more sophisticated features. Frameworks for JavaScript and Node.js include di, Proxyquire, Bottle, Electrolyte and Wire. In TypeScript, Inversify is a popular choice.
Angular provides built-in dependency injection. [1] Angular identifies injectable modules by the @Injectable
annotation. Angular will inject such modules when constructing new objects whenever the constructor has parameters with matching types. For example, consider the following code:
class Item {
constructor(private notificationService: NotificationService) {
// implementation using notificationService
}
...
}
In this code, Angular detects that the constructor of Item
requires a notificationService. Angular will automatically supply an @Injectable
implementation of the NotificationService
interface. [2]
Inversion of control in Express
Dependency injection is a more specific instance of a general principle of inversion of control. Poor dependency management is one cause of unmanageable complexity, but other factors add complexity:
-
Using one code-base to support different development, test and production environments
-
Supports optional components to be plugged-in or substituted
-
Using one code-base for diverse deployments (e.g., both local installation and a distributed cluster)
-
Top-level event-loops or triggers that must be regularly run
-
Commands triggered by external factors (e.g., user or sensor inputs), rather than internal control-flow
If you are working on a problem with complex code that cannot be simplified, it may be worth pausing to ask yourself whether you can reverse the responsibilities. For example, the src/server/domain.js
is responsible for creating a new Item
and adding it to the persistence layer. A reversal would involve a new Item
adding itself to the persistence layer automatically. Alternatively, the persistence layer could simulate an infinite list of items to be configured, so that the persistence layer instantiates each new Item
.
The architecture of Express uses the principle of inversion of control. When you write an Express application, you do not need to write code that receives a network request and identifies a method to call.
In other words, Express does NOT require the following code: [3]
const notExpress = require('not-express');
const port = 3000;
let connection = notExpress.open(port);
while (true) {
// Wait for the next request
let request = connection.waitForNextRequest();
// Then handle the request
if (request.path == '/items') {
request.respondWith({ success: true, items: persistence.findActive() });
}
}
Instead, Express follows the “Hollywood principle”: routes are configured with callbacks and then our code follows a “don’t call Express; wait for Express to call me” principle:
// This Express code sets up inversion of control (the Hollywood Principle)
// When Express receives a GET request, Express calls the supplied function
route.get('/items', (req, res) => {
res.json({ success: true, items: persistence.findActive() });
});
Microkernel architecture
An extreme variant of inversion of control is the microkernel architecture. This architecture uses a small core (or kernel) as a general framework for the application. The kernel allows every single module and feature to be ‘plugged in’ to the system. You would be creating a microkernel architecture if you were making extensive use of inversion of control and dependency injection so that every aspect of the application is modular.
For example, a simple interpretation of a microkernel architecture would be an Express application that automatically scans for routes:
const express = require('express');
const path = require('path');
const fs = require('fs');
const port = 3000;
const app = express();
// The plugins directory contains modules for this application
// The default export must be an express.Router()
const pluginsPath = path.join(__dirname, 'plugins/');
// Load each of the plugins as an express route
const plugins = fs.readdirSync(pluginsPath);
for (let plugin of plugins) {
app.use(require(path.join(pluginsPath, plugin)));
}
// Start the server immediately
app.listen(port, () => console.log(`The application is running on http://localhost:${port}/`));
Note that this microkernel is completely flexible. It does not hard-code any functionality. The kernel detects any module added to the plugins/
directory.
Use microkernels with caution. Their simplicity is alluring. However, a poorly designed microkernel may provide too much flexibility. If everything is modular, then nothing can be trusted. Also, the flexibility of modules makes it difficult for tools and other developers to understand the execution. For example, a compiler or transpiler such as webpack
will not compile the plugins/
directory if it does not understand the scheme used by the microkernel.