📜 ⬆️ ⬇️

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:


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 high coupling , 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:



Minuses:



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:



Minuses:



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 wider 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:



Minuses:



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). Implement a DI container using parsing constructor arguments:

 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:



The biggest minus:



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.

Source: https://habr.com/ru/post/438546/