Make programming easier

Introduction
Do you need Dependency Injection (DI) in Javascript? [WIP]

Do you need Dependency Injection (DI) in Javascript? [WIP]

Dependency Injection is a pattern that knows how to create instances of objects (called services) and its dependencies necessary to make an app work.

DI knows how to create a service or where to find its dependencies. For example ArticleController might need access to ArticleRepository, EventBus for sending events or ACL for asserting permissions.

Example that uses alpha-dic

import {AutowiredService} from 'alpha-dic';

@AutowiredService()
class ArticleController {
    // alpha-dic knows what needs to be injected due to typescript metadata
    constructor(private articleRepository: ArticleRepository,
                private eventBus: EventBus,
                private acl: ACL) {
                           
    }
}

But JS devs are used to work in different approach. Just export a controller in a module and import everything else.

import {acl} from '../acl';
import {eventBus} from '../eventBus';

export default {
    findArticles(req) {
    	acl.assertPermissions(req.user, 'articles');
    },
}

Both approaches have their cons and pros I'd like to discuss.

Module export vs creating instances

When module exports a static object that you can use directly all your dependencies have to be imported. That means if any of those dependencies have any state (database connection, cache, http clients) they are responsible for creating it. This is not always easy and might significantly affect the design of a module you're creating.

For example if acl dependency requires database connection created asynchronously you need to setup that state before using your module.

// acl.ts
let permissions: Record<string, Record<number, boolean>>;
export async function initialize() {
   const connection = await connect();

   permissions = await loadPermissions(connection);
}

export function assertPermission(user: User, type: string) {
    const userPermissions = permissions[user.id];
    const hasPermission = userPermissions ?? (userPermissions[type] ?? false);
    if (!hasPermission) {
        throw new Error('Insufficient permissions');
    }
}

// articlesService.ts
import {assertPermission} from './acl';
export default {
    findArticles(user: User) {
        // make user user have access to articles
        assertPermission(user, 'articles');
    }
}

// app.ts
import * as articlesService from './articlesService';

(async() => {
    // setup acl first
    await require('./acl').initialize()

    articlesService.findArticles()
});

In other cases you might be forced to make module methods asynchronous which affects every dependency consumer as well

// acl.ts

const getConnectionAndLoadPermissions = _.memoize(async () => {
    const connection = await connect();
    return loadPermissions(connection); // load all permissions from database
})

// note this is async now
export async function assertPermission(user: User, type: string) {
    const permissions = await getConnectionAndLoadPermissions();

    const userPermissions = permissions[user.id];
    const hasPermission = userPermissions ?? (userPermissions[type] ?? false);
    if (!hasPermission) {
        throw new Error('Insufficient permissions');
    }
}

// articlesService.ts
import {assertPermission} from './acl';
export default {
    // note this is async now as well
    async findArticles(user: User) {
        // make user user have access to articles
        await assertPermission(user, 'articles');
    }
}

// app.ts
import * as articlesService from './articlesService';

(async() => {
    await articlesService.findArticles()
});

With approach of creating an instance of a module (class or just factory function) the problem of dependencies with state does not exist since you are forced to create the module first and therefore you can wait for dependencies setup to finish.

Types of dependencies

We can distinguish few types of dependencies

  • without state
  • with state
    • created synchronously
    • created asynchronously

Dependencies without state

Those dependencies are mostly helper methods that does not require any setup or internal state. Therefore they're the easiest to use and work with

// this module has no initial state
import {debounce} from 'lodash';

export const loadUsers = debounce(() => {}, 100);

Dependencies with state

This is the most interesting part and also the most problematic.
A dependency might need some state in order to work properly. This could be a database connection, a HTTP client, a cache, file handler or anything else.

Dependency with state created synchronously

For example purposes lets create a dependency responsible for rendering templates.

Module approach

import * as fs from 'fs';
import * as path from 'path';

const templates: Record<string, (variables: Record<string, unknown>) => string> = {};
// iterate over template files and precompile them
for(const file of fs.readdirSync('./templates/')) {
    templates[path.basename(file)] = compileTemplate(fs.readFileSync(file));
}
export function render(templateName: string, variables: Record<string, unknown>) {

}

In that approach the state is created once module is imported. At this point the state cannot be modified unless you create extra functions to do so. Moreover you cannot easily disable state creation (for testing purposes). The only way to control the module is mocking it entirely (for example throught jest.mock) or global configuration flags (and you need to remember to set it before importing the module).

Another problem with that approach is limited possibilities of configuration. In that case probably the only way to change templates directory is using ENV variable.

Reusability is also very limited. Imagine you need to read templates for other directory. What to do now? Adding boilerplate function for each directory?

Despite the problems described above there is one great benefit. They are easy to use. Just import the module and use it.

Class approach

import * as fs from 'fs';
import * as path from 'path';

export class TemplateRenderer {
    private templates: Record<string, (variables: Record<string, unknown>) => string> = {};

    constructor(directory: string) {
        for (const file of fs.readdirSync(directory)) {
            this.templates[path.basename(file)] = compileTemplate(fs.readFileSync(file));
        }
    }

    render(name: string, variables: Record<string, unknown>) {

    }
}

The state is created only when instance of TemplateRenderer is created. You can import a module without side-effects and create multiple instances of TemplateRenderer which makes it configurable and reusable.

How about modyfing state? Just destroy an instance of TemplateRenderer.

The comparison would not be fair without saying how to define it in DI.

import {createStandard, config, AutowiredService} from 'alpha-dic';
import {TemplateRenderer} from './TemplateRenderer';

const container = createStandard({
    config: {
        templates: {
            directory: './templates'
        }
    }
});
container.definitionWithClass(TemplateRenderer)
    .withArgs(config('templates.directory'))

// using autowiring to automatically inject TemplateRenderer
@AutowiredService()
class Test {
    constructor(private templateRenderer: TemplateRenderer) {
    }
}

Example with multiple instances or TemplateRenderer

import {createStandard, config, AutowiredService} from 'alpha-dic';
import {TemplateRenderer} from './TemplateRenderer';

const container = createStandard({
    config: {
        templates: {
            mailingDirectory: './templates/mailing',
            websiteDirectory: './templates/website'
        }
    }
});

// The easiest way to create different instances of TemplateRenderer that takes advantage of autowiring
@AutowiredService()
class MailingTemplateRenderer extends TemplateRenderer {
    constructor(@Config('templates.mailingDirectory') directory: string) {
        super(directory);
    }
}

@AutowiredService()
class WebsiteTemplateRenderer extends TemplateRenderer {
    constructor(@Config('templates.websiteDirectory') directory: string) {
        super(directory);
    }
}

// using autowiring to automatically inject dependencies
@AutowiredService()
class Test {
    constructor(private templateRenderer: MailingTemplateRenderer) {
    }
}

@AutowiredService()
class Test2 {
    constructor(private templateRenderer: WebsiteTemplateRenderer) {
    }
}

Dependency with state created asynchronously

This problem is much different than before. Since the state creation is asynchronous you need to wait for the state to be created before using a dependency. Let's redefine the example with rendering templates.

Module approach

import * as fs from 'fs';
import * as path from 'path';

const templates: Record<string, (variables: Record<string, unknown>) => string> = {};

export async function loadTemplates(directory) {
    // iterate over template files and precompile them
    for (const file of await fs.promises.readdir(directory)) {
        templates[path.basename(file)] = compileTemplate(fs.readFileSync(file));
    }
}
export function render(templateName: string, variables: Record<string, unknown>) {

}

Example usage in the app

import {render, loadTemplates} from './templateRenderer';
(async () => {
    // you cannot use templates without awaiting for loading 
    await loadTemplates('./templates');
    render('test', {});
})()

Much better. Now you can control when state is created by calling loadTemplates function (with configuration) but not modify/clear it since the templates dictionary is stored inside the module.

Class approach

import * as fs from 'fs';
import * as path from 'path';

export class TemplateRenderer {
    private templates: Record<string, (variables: Record<string, unknown>) => string> = {}

    constructor(private directory: string) {
    }

    render(name: string, variables: Record<string, unknown>) {

    }

    async load() {
        for (const file of await fs.promises.readdir(this.directory)) {
            this.templates[path.basename(file)] = compileTemplate(fs.readFileSync(file));
        }
    }
}

Usage with DI

import {createStandard, config, AutowiredService, onActivation} from 'alpha-dic';
import {TemplateRenderer} from './TemplateRenderer';

const container = createStandard({
    config: {
        templates: {
            directory: './templates'
        }
    }
});
container.definitionWithClass(TemplateRenderer)
    .annotate(onActivation(async (renderer: TemplateRenderer) => {
        // call `load` upon service creation
        await renderer.load();
        return renderer;
    }))
    .withArgs(config('templates.directory'))

// using autowiring to automatically inject TemplateRenderer
@AutowiredService()
class Test {
    constructor(private templateRenderer: TemplateRenderer) {
    }
}

Multiple instances

import {createStandard, config, AutowiredService, OnActivation} from 'alpha-dic';
import {TemplateRenderer} from './TemplateRenderer';

const container = createStandard({
    config: {
        templates: {
            mailingDirectory: './templates/mailing',
            websiteDirectory: './templates/website'
        }
    }
});

@AutowiredService()
@OnActivation(async (templateRenderer: TemplateRenderer) => {
    await templateRenderer.load()
    return templateRenderer;
})
class MailingTemplateRenderer extends TemplateRenderer {
    constructor(@Config('templates.mailingDirectory') directory: string) {
        super(directory);
    }
}

@AutowiredService()
@OnActivation(async (templateRenderer: TemplateRenderer) => {
    await templateRenderer.load()
    return templateRenderer;
})
class WebsiteTemplateRenderer extends TemplateRenderer {
    constructor(@Config('templates.websiteDirectory') directory: string) {
        super(directory);
    }
}

// using autowiring to automatically inject dependencies
@AutowiredService()
class Test {
    constructor(private templateRenderer: MailingTemplateRenderer) {
    }
}

@AutowiredService()
class Test2 {
    constructor(private templateRenderer: WebsiteTemplateRenderer) {
    }
}

Mocking and stubbing

// TODO

Nobrainer mode

In my opinion DI is a nobrainer choice since you don't need to care about all the problems that regular module dependencies with state create. You don't care how those modules are created, what setup is needed since it is no longer a problem for dependencies consumers. Even though you need to do a little bit more boilerplate during testing it is only for your overall benefit.

Some argue that a need to learn DI or similar tools is an argument against using it. Well... In order to query database you need to learn about ORM (which is far more complex) or library for querying a database or would you rather reimplement it on your own everytime?

Conclusion

As always with everything. Use the tool that you (and your team) feel comfortable with. Even if you chose to stick with modules - thats OK. I just gave You brief description of possible problems and solutions you might rethink later.

Author

wookieb

Fullstack developer with 10 years of experience. Passionated about programming, computers and how things work.

View Comments
Previous Post

Is `instanceof` broken?