Home > Software engineering >  Passing modules as argument parameters to other modules constructors: good or bad practise?
Passing modules as argument parameters to other modules constructors: good or bad practise?

Time:12-04

I've seen the following construct used in code at my work and find it confusing to read, although I understand that it works.

Example:

// A.js

const aFunc = () => { console.log("a function") }

const aVariable = 42

exports.aFunc = aFunc
exports.aVariable = aVariable
// B.js

let libRef

const init = (libReference) => {
    libRef = libReference
}

const useLib = () => {
    libRef.aFunc()
    console.log(libRef.aVariable)
}

exports.init= init
exports.useLib= useLib
// C.js

const libA = require("<path_to_A.js>")
const libB = require("<path_to_B.js>")

// libA.aFunc()
// console.log(libA.aVariable)

libC.init(libA)
libC.useLib()

Module B.js doesn't need a require statement to now use the exported components from module A.js since it's passed this library as a reference.

I realise this is perhaps a slightly subjective question but is this common practise? I find it unecessarily confusing and another drawback is that you cannot use "go-to-declaration" when working on module B.js since it doesn't know what libRef.aFunc() without the require statement in the same file.

CodePudding user response:

At that point you're not really passing modules, you're passing objects. Passing objects and other objects for them to use allows for a looser type of coupling. As long as LibB receives an object with the properties and methods that expects, it gets to be blissfully ignorant of where that object came from.

Explicitly referring to LibA within LibB will not have that flexibility.

See https://youtu.be/wfMtDGfHWpA for an entertaining explanation of this concept and of why this is a good idea.

CodePudding user response:

I'll try to sum up the ground that was covered in my comment discussion in one of the other answers...

Summary

My answer is "it depends". Usually a module will just load its own dependencies. Very occasionally, you want the caller to be able to provide their own interface for some piece of functionality (as in my logging example) in which case you may use a scheme like your question shows.

I realize this is perhaps a slightly subjective question but is this common practice?

No. Most modules in nodejs development do not load dependencies this way. So, I'd say its not the common way to do it.

In the infrequent cases where you want to require the caller to supply an interface to your module rather than just getting it directly from a locally imported module, this is one way to do it.

I find it unnecessarily confusing and another drawback is that you cannot use "go-to-declaration" when working on module B.js since it doesn't know what libRef.aFunc() without the require statement in the same file.

This certainly does make the code more difficult to follow. It's something you could learn over time, but it's definitely not as obvious to follow as const anInterface = require(someModule);.

And, yes this does break static dependency analysis that numerous development tools use (like editors or bundlers) because it turns the dependencies from static to dynamic (only known at run-time).

Discussion and Explanation

In Javascript, a module typically exports an object that contains an interface. That interface typically consists of a parent object with various properties on that object that are usually functions you can call.

When you do require(someModule), you get the object that was exported from that module. Now, in Javascript, nothing is strictly typed so ANY object that contains the appropriate properties that match the desired interface can be used. In more strongly typed languages, this might be called an Interface object or it could even be an instance of a class. In all cases, it's just a specification for what properties exist and what can be done with them.

In a typical use of a module in nodejs such as the fs module, you would just do something like this:

const fs = require('fs');

const myData = fs.readFileSync("myfile.txt");

Since the fs module is a built-in module and this module wants to use it, it just makes sense that this module just imports it with require() so it can use it. There is no particular reason in this case for the fs interface to come from any other place. In fact, doing it this way makes it very self describing. This module has one simple dependency on the fs module. It's all right there in the code.

In all my nodejs programming, this is the way I've done it 99.9% of the time. It's simple, straightforward, self-documenting, works with dependency analyzers used in bundling, is recognizable to all nodejs developers, etc...


Occasionally, very occasionally, you may want to offer the user of your module the ability to supply an implementation of some interface that this module uses so that they can determine how it works. For example, imagine that you're doing some low level module that might be used in a high performance server and you want to have an ability to turn on different levels of logging in the module and you want the user of your module to be able to determine where that logged information goes. Some users of your module may want to use the regular console object for logging. Other users of your module may want to use some custom logging option such as Winston.

When designing and implementing your module, you don't want to have to implement all possible ways that all possible users of your module will do logging so you want to make the logging mechanism is extensible so the user of your module can supply their own logging solution - no matter what it is. In that case, you may offer a feature in your module that the user of your module can provide you an object that supports a specific logging interface (a minimal set of methods that must exist on that object). Probably, you have a default logging implementation that uses the console object, but if the loader of your module wants to, they can supply their own logging interface object instead.

The code you show in your question is one such way to allow the loader of a module to supply an interface to it that the module will then use.

I personally would only add that level of additional code and burden on the caller when there was a specific and compelling reason to do so. If you wrote a module that needed to load 10 other modules to do its work, you're unlikely to require the caller of your module to manually create or load 10 interface modules just to use your module - that's just the hard way of doing things. Better to have your module load its own dependencies in one common place rather than require every user of the module to do so. So, clearly there's a balance here. You wouldn't always require the caller to supply all possible dependent interfaces. And, as I've described above, there is occasionally a good design reason to allow the caller to supply an interface.

  • Related