Dependency injection in ES6+ “on the fingers”

In my previous post, I tried to explain what “inversion of control” is and in what cases the use of dependency injection in JS (ES6+) becomes justified (if you have dozens of npm packages in your codebase, each of which has hundreds or even thousands of es6 modules). In this post, I will show step by step how you can build your own object container that will download the source code of es6 modules, create the necessary dependencies and insert them in the right places. I immediately warn you that in order to simplify the presentation in the demo code, certain assumptions related to the generation of objects will be used. The purpose of the article is to demonstrate the actual technology of injection of dependence, and not a ready-made “all-weather” solution. As a result, you should have an understanding of how you can make your own object container in ES6+ if you suddenly need it for some reason.

I divided the entire post into 7 parts so that moving from part to part, the functionality of the demo code was increased up to the current container of objects.

1. Composition root

In the last post, I explained how “reverse control” when forming a tree of objects in an application differs from “direct control”. I will repeat briefly. Direct control is when the source code of the object being created contains paths to the source codes of dependencies (static imports), and with reverse control, the object delegates the creation of dependencies to an external agent (object container), and itself only provides an injection mechanism for dependencies (for example, through a constructor).

Here is an example of direct control (via static imports):

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 reverse control (via constructor parameters):

export default class Service {
    #logger;

    constructor(logger) {
        this.#logger = logger;
    }

    exec(opts) {
        this.#logger.info(`Service is running with: ${JSON.stringify(opts)}`);
    }
}

The main conclusion for this part is that if dependencies are injected by an external agent, then there must be a place in the application code where the source code of these dependencies is loaded, dependencies are created and then injected:

import logger from './logger.js';
import Service from './service.js';

const srv = new Service(logger);
srv.exec({name: 'The Basics of IoC'});

This place in the program is called the composition root. If we have dependency injection, we definitely have a composition root.

2. Specification of dependencies

The JavaScript language (ES6+) cannot boast of a developed toolkit for analyzing its own code (reflection). It does not have the ability to analyze the types of constructor arguments, and the names of the arguments themselves can be changed during the code minification process. But if we agree that all the necessary dependencies fall into the constructor in the form of a single object – the specification:

class Service {
    constructor(spec) { }
}

where each specification property represents a separate dependency:

class Service {
    constructor({logger, config}) { }
}

then we protect ourselves from changing the names of dependencies and get the opportunity to analyze them (names).

3. Factory

Classes are syntactic sugar and object creation can be done with normal functions (factories):

function Factory({dep1, dep2, ...}) {
    return function (opts) {/* use deps here */};
}

Suppose that each es6 module exports by default such an asynchronous factory that takes a dependency specification as input, and the resulting object is the output:

// ./logger.js
export default async function Factory() {
    return {
        error: (msg) => console.error(msg),
        info: (msg) => console.info(msg),
    };
};
// ./service.js
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:

// ./main.js
import fLogger from './logger.js';
import fService from './service.js';

const logger = await fLogger();
const serv = await fService({logger});
serv({name: 'The Basics of Factories'});

4. Imports

Suppose the dependencies in the specs are paths to es6 modules with dependency factories:

const spec = {
    dep1: './path/to/the/dep1/module.js',
    dep2: './path/to/the/dep2/module.js',
    ...
};

Then we can use dynamic imports inside factory functions that need dependencies to create the resulting objects:

// ./service.js
export default async function Factory({logger: pathToLogger}) {
    // begin of DI functionality workaround
    const {default: fLogger} = await import(pathToLogger);
    const logger = await fLogger();
    // end of DI functionality workaround
    return function (opts) {
        logger.info(`Service is running with: ${JSON.stringify(opts)}`);
    };
}

So at this point we can get rid of static imports entirely:

// ./main.js
const {default: fService} = await import('./service.js');
const serv = await fService({logger: './logger.js'});
serv({name: 'The Basics of Import'});

5. Proxy

For dependency analysis, developers awilix suggested to use Proxy object:

export default new Proxy({}, {
    get(target, prop) {
        console.log(`proxy: ${prop}`);
        return target[prop];
    }
});

In this case, we can move the loading and creation of dependencies from services into the specification:

// ./spec.js
// workaround to load 'logger' dep
import fLogger from './logger.js';
const logger = await fLogger();
// end of workaround
export default new Proxy({}, {
    get(target, prop) {
        return (prop === './logger.js') ? logger : target[prop];
    }
});

Factory function for service:

// ./service.js
export default function Factory({['./logger.js']: logger}) {}

Composition root for this case looks like this:

// ./main.js
import spec from './spec.js';
import fService from './service.js';

const serv = await fService(spec);
serv({name: 'The Basics of Spec Proxy'});

We’ve moved importing sources and creating dependencies into the spec. But for this trick to work, the specification needs to know in advance about all the dependencies of the project.

6. Container

We can’t know about all of the project’s dependencies in advance, but we can analyze the dependencies as the objects request them through the proxy specification.

Let us have the following code for the proxy specification in the container:

// ./container.js
const DEP_KEY = 'depKey'; // key for an exception to transfer dependency key
const deps = {}; // all created deps

const proxy = new Proxy({}, {
    get(target, prop) {
        if (deps[prop]) return deps[prop];
        else {
            const e = new Error('Unresolved dependency');
            e[DEP_KEY] = prop;
            throw e;
        }
    }
});

That is, if in the object deps there is a dependency we need, then the proxy specification returns it to us, if not, it throws an exception and adds the identifier of the requested dependency to it (the path to the file with the source code for import).

Let in the same module ./container.js there is a function that uses the asynchronous object factory from point 3:

// ./container.js - продолжение
async function useFactory(fnFactory) {
    let res;
    // try to create the Object
    do {
        try {
            // Object is created when all deps are created
            res = await fnFactory(proxy);
        } catch (e) {
            if (e[DEP_KEY]) {
                // we need to import another module to create dependency
                const depKey = e[DEP_KEY];
                const {default: factory} = await import(depKey);
                deps[depKey] = await useFactory(factory);
            } else {
                // this is a third-party exception, just re-throw
                throw e;
            }
        }
        // if Object is not created then retry (some dep was not imported yet)
    } while (!res);
    return res;
}

This function in a loop tries to construct the required object using factory functions (see point 3), which are exported by default. A proxy is passed as the specification for the constructed object factory. If the proxy finds all the dependencies needed to construct the object, then the object is created. If the proxy does not find the required dependency, then the function useFactory catches the exception, extracts the path to the file with the source code of the dependency from it, imports the module, creates the required dependency and places it in the registry deps with the appropriate code, and then retrying the factory function. This point must be taken into account, because. the number of times a container runs a factory function before creating an object can be much larger than the number of dependencies in it (dependencies of dependencies, etc.).

Well, at the end we have the code of the container itself:

// ./container.js - продолжение
export default {
    /**
     * Get some object from the Container.
     * @param {string} key
     * @return {Promise<*>}
     */
    get: async function (key) {
        const {default: factory} = await import(key);
        const res = await useFactory(factory);
        deps[key] = res;
        return res;
    }
};

In general, now we have a container (./container.js) and is the composition root.

full container source code
const DEP_KEY = 'depKey'; // key for exception to transfer dependency key (path for import)
const deps = {}; // all created deps

const proxy = new Proxy({}, {
    get(target, prop) {
        if (deps[prop]) return deps[prop];
        else {
            const e = new Error('Unresolved dependency');
            e[DEP_KEY] = prop;
            throw e;
        }
    }
});

async function useFactory(fnFactory) {
    let res;
    // try to create the Object
    do {
        try {
            // Object is created when all deps are created
            res = await fnFactory(proxy);
        } catch (e) {
            if (e[DEP_KEY]) {
                // we need to import another module to create dependency
                const depKey = e[DEP_KEY];
                const {default: factory} = await import(depKey);
                deps[depKey] = await useFactory(factory);
            } else {
                // this is a third-party exception, just re-throw
                throw e;
            }
        }
        // if Object is not created then retry (some dep was not imported yet)
    } while (!res);
    return res;
}

export default {
    /**
     * Get some object from the Container.
     * @param {string} key
     * @return {Promise<*>}
     */
    get: async function (key) {
        const {default: factory} = await import(key);
        const res = await useFactory(factory);
        deps[key] = res;
        return res;
    }
};

The main application module becomes this:

// ./main.js
import container from './container.js';
const serv = await container.get('./service.js');
serv({name: 'The Basics of Container'});

In fact, we moved the paths to es6 modules with source code from static imports to dependencies in factory functions and replaced the static imports themselves with dynamic imports in the functions useFactory.

7. Dependency map

Let’s add a dependency map to our object container to get rid of the paths to modules with source codes in the code of object factories:

// ./container.js
...
const map = {}; // objectKey-to-importPath map
...

async function useFactory(fnFactory) {
    let res;
    do {
        try {...} catch (e) {
            if (e[DEP_KEY]) {
                ...
                const path = map[depKey] ?? depKey;
                const {default: factory} = await import(path);
                ...
            } else {... }
        }
        // if Object is not created then retry (some dep was not imported yet)
    } while (!res);
    return res;
}

export default {
    /**
     * Get some object from the Container.
     * @param {string} key
     * @return {Promise<*>}
     */
    get: async function (key) {
        const path = map[key] ?? key;
        const {default: factory} = await import(path);
        ...
    },
    setMap: function (data) {
        Object.assign(map, data);
    },
};

Now we can use “logical” dependency names in our code:

// ./service.js
export default async function Factory({logger, config}) {
    return function (opts) {
        logger.info(`Service '${config.appName}' is running with: ${JSON.stringify(opts)}`);
    };
}

The container independently converts the logical names of dependencies into paths to es6 modules using a map. You just need to set it in the main application file:

// ./main.js
import container from './container.js';

container.setMap({
    service: './service.js',
    logger: './logger.js',
    config: './config.js',
});
const serv = await container.get('service');
serv({name: 'The Basics of Resolver'});

The advantage of “mapping” dependencies is that the same dependencies in the code can be reflected in different paths to es6 modules. For example, the same set of modules can be loaded both on the back and on the front – you just need to specify the appropriate map:

const mapBack = {
    service: '/absolute/path/to/service.js',
    logger: '/absolute/path/to/logger.js',
    config: '/absolute/path/to/config.js',
};

const mapFront = {
    service: 'https://domain.com/app/service.js',
    logger: 'https://domain.com/app/logger.js',
    config: 'https://domain.com/app/config.js',
};

Summary

This container of objects is written with a lot of conventions, which is why it turned out to be so small (only 50-60 lines of code). However, I hope it covers the whole idea of ​​how you can add dependency injection to your project. Since the dependency key can be any string, enough information can be encoded in it so that not only object factories can be extracted from the es6 module, but also ordinary functions, classes, objects, make them singletons or separate instances (transient). You can add conditions to maps for the implementation of “interfaces” and for replacing some modules with others (in especially exotic cases). In general, there is room for experimentation.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *