§10.6.

Inversion of control

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.

Reflection: Inversion of control

I have listed several approaches to inversion of control (IoC). Considering the principle of ‘minimum viable architecture’, is IoC appropriate in the team shopping list? If so, where and how should it be implemented? If not, why not?

Advanced exercise: Logging

Can you extend one of the simple examples of dependency injection to add logging capabilities?

For example, calling notificationService.notifyNewItem(…​) should not only perform the “email” notification but also log to the console an addition message to say notificationService.notifyNewItem called with parameters …​


1. Angular’s dependency injection framework is available in non-Angular projects by the injection-js package.
2. Interfaces are part of TypeScript. They are not available in ordinary JavaScript.
3. This kind of top-level request handling loop is common in socket-based web server code in other programming languages.