Analysis of module binding approaches in Node.js

    Many Node.js developers use modules (only) to create hard dependencies using require (), but there are other approaches with their own advantages and disadvantages. I will tell about them in this article. Four approaches will be considered:

    • Hard dependencies (require ())
    • Dependency Injection
    • Service Locators
    • Inventory Dependency Containers (DI Container)

    Little about modules


    Modules and modular architecture are the foundation of Node.js. Modules provide encapsulation (hiding implementation details and opening only the interface using module.exports), code reuse, logical code breaking into code. Almost all Node.js applications consist of a set of modules that must interact in some way. If it is wrong to connect modules or to let the interaction of modules take place completely, then you can very quickly find that the application begins to “collapse”: code changes in one place lead to breakdowns in another, and unit testing becomes simply impossible. Ideally, modules should have a high connectivity , but low coupling .

    Hard dependencies


    Hard dependence of one module on another arises when using require (). This is an effective, simple and common approach. For example, we just want to connect the module responsible for interacting with the database:

    // ourModule.js
    const db = require('db');
    // Работа с базой данных...

    Pros:


    • Simplicity
    • Visual organization of modules
    • Easy debugging

    Minuses:


    • Difficulty in reusing a module (for example, if we want to reuse our module, but with a different DB instance)
    • Difficulty for unit testing (you have to create a dummy instance of the database and somehow transfer it to the module)

    Summary:


    The approach is good for small applications or prototypes, as well as for connecting stateless modules: factories, constructors, and feature sets.

    Dependency Injection


    The main idea of ​​dependency injection is to transfer a dependency module from an external component. Thus, the hard dependency in the module is eliminated and it becomes possible to reuse it in different contexts (for example, with different database instances).

    Dependency injection can be done by passing dependencies in the constructor argument or by setting the properties of the module, but in practice it is better to use the first method. Let's apply dependency injection in practice by creating a database instance using a factory and passing it to our module:

    // ourModule.js
    module.exports = (db) => {
    // Инициализация модуля с переданным экземпляром базы данных...	
    };

    External module:

    const dbFactory = require('db');
    const OurModule = require('./ourModule.js');
    const dbInstance = dbFactory.createInstance('instance1');
    const ourModule = OurModule(dbInstance);

    Now we can not only re-use our module, but also easily write a unit test for it: it is enough to create a mock object of the database instance and transfer it to the module.

    Pros:


    • Ease of writing unit tests
    • Increase in "reusability" of modules
    • Reduced gearing, increased connectivity
    • Shifting responsibility for creating dependencies to a higher level - often this improves the readability of the program, since important dependencies are collected in one place and not spread over modules

    Minuses:


    • The need for a more thorough design of dependencies: for example, a certain order of initialization of modules must be observed
    • The complexity of managing dependencies, especially when there are many
    • Impaired module code clarity: it is more difficult to write module code when a dependency comes in from the outside, because we cannot directly look at this dependency.

    Summary:


    Dependency injection increases the complexity and size of the application, but instead gives reusability and facilitates testing. The developer should decide what is more important for him in a particular case - the simplicity of a hard dependency or more extensive possibilities of dependency injection.

    Service Locators


    The idea is to have a dependency registry, which acts as an intermediary when loading a dependency with any module. Instead of hard binding, dependencies are requested by the module from the service locator. Obviously, modules have a new dependency - the service locator itself. An example of a service locator is the Node.js module system: modules request a dependency using require (). In the following example, we will create a services locator, register instances of the database and our module in it.

    // serviceLocator.js
    const dependencies = {};
    const factories = {};
    const serviceLocator = {};
    serviceLocator.register = (name, instance) => { //[2]
      dependencies[name] = instance;
    };
    serviceLocator.factory = (name, factory) => { //[1]
      factories[name] = factory;
    };
    serviceLocator.get = (name) => { //[3]
      if(!dependencies[name]) {
        const factory = factories[name];
        dependencies[name] = factory && factory(serviceLocator);
        if(!dependencies[name]) {
          throw new Error('Cannot find module: ' + name);
        }
      }
      return dependencies[name];
    };

    External module:

    const serviceLocator = require('./serviceLocator.js')();
    serviceLocator.register('someParameter', 'someValue');
    serviceLocator.factory('db', require('db'));
    serviceLocator.factory('ourModule', require('ourModule'));
    const ourModule = serviceLocator.get('ourModule');

    Our module:
    // ourModule.js
    module.exports = (serviceLocator) => {
      const db = serviceLocator.get('db');
      const someValue = serviceLocator.get('someParameter');
      const ourModule = {};
      // Инициализация модуля, работа с БД...
      return ourModule;
    };

    It should be noted that the service locator stores service factories instead of instances, and this makes sense. We got the advantages of “lazy” initialization, moreover, now we can not care about the order of initialization of modules - all modules will be initialized when it is needed. Plus, we were able to store parameters in the service locator (see someParameter).

    Pros:


    • Ease of writing unit tests
    • Module reuse is easier than with hard dependency.
    • Reduced engagement, increased connectivity compared to hard dependency
    • Shifting responsibility for creating dependencies to a higher level
    • There is no need to follow the module initialization order

    Minuses:


    • Module reuse is more complicated than with dependency injection (due to the additional service locator dependency)
    • Readability: it's even harder to understand what the dependency required by the service locator does
    • Increased engagement compared to dependency injection

    Summary


    In general, the service locator is similar to dependency injection, in some ways it is easier (there is no initialization order), in some cases it is more complicated (less possibility to reuse code).

    Inventory Dependency Containers (DI Container)


    The service locator has a flaw because of which it is rarely used in practice - the dependence of the modules on the locator itself. Containers of implemented dependencies (DI-containers) are free from this drawback. In essence, this is the same service locator with an additional function that determines the dependencies of a module before creating its instance. You can determine the dependencies of a module by parsing and extracting arguments from the module's constructor (in JavaScript, you can cast a function reference to a string using toString ()). This method is suitable if the development is purely for the server. If client code is written, then it is often minified and it will be meaningless to extract the argument names. In this case, the list of dependencies can be transferred by an array of strings (in Angular.js, based on the use of DI-containers, this is the approach that is used).

    const fnArgs = require('parse-fn-args');
    module.exports = function() {
      const dependencies = {};
      const factories = {}; 
      const diContainer = {};
      diContainer.factory = (name, factory) => {
        factories[name] = factory;
      };
      diContainer.register = (name, dep) => {
        dependencies[name] = dep;
      };
      diContainer.get = (name) => {
        if(!dependencies[name]) {
          const factory = factories[name];
          dependencies[name] = factory && diContainer.inject(factory);
        if(!dependencies[name]) {
          throw new Error('Cannot find module: ' + name);
        }
      }
      diContainer.inject = (factory) => {
        const args = fnArgs(factory)
          .map(dependency => diContainer.get(dependency));
        return factory.apply(null, args);
      }
      return dependencies[name];
    };

    Compared to the service locator, the inject method has been added, which determines the dependencies of the module before creating its instance. The code of the external module has not changed:

    const diContainer = require('./diContainer.js')();
    diContainer.register('someParameter', 'someValue');
    diContainer.factory('db', require('db'));
    diContainer.factory('ourModule', require('ourModule'));
    const ourModule = diContainer.get('ourModule');

    Our module looks exactly the same as with simple dependency injection:

    // ourModule.js
    module.exports = (db) => {
    // Инициализация модуля с переданным экземпляром базы данных...	
    };

    Now our module can be called either using the DI container, or by passing the necessary dependency instances directly to it using simple dependency injection.

    Pros:


    • Ease of writing unit tests
    • Easy module reuse
    • Reduced engagement, increased module connectivity (especially when compared to a service locator)
    • Shifting responsibility for creating dependencies to a higher level
    • There is no need to follow the module initialization order.

    The biggest minus:


    • Significant complication of module linking logic

    Summary


    This approach is more difficult to understand and contains a bit more code, but it is worth the time spent on it because of its power and elegance. In small projects, this approach may be redundant, but it should be considered if a large application is being designed.

    Conclusion


    The main approaches to linking modules in Node.js were considered. As is usually the case, there is no “silver bullet”, but the developer should be aware of the possible alternatives and choose the most suitable solution for each specific case.

    The article is based on a chapter from the Node.js Design Patterns book published in 2017 . Unfortunately, many things in the book are already outdated, so I can not 100% recommend it for reading, but some things are still relevant today.

    Also popular now: