Dependency Identifier Description Format in JS DI

This article is for those who know what “dependency injection” and has practical experience of its use. My name is Alex Gusev and I am the author of the library “@teqfw/di“The goal of my library is to provide the ability to use the functionality of “constructor-based dependency injection” in JS (front and back) and TS (back) projects. The minimum implementation unit is a separate export of an es6 module. Therefore, the library cannot be used with CJS or UMD modules.

The idea behind dependency injection is that instead of statically linking source code at the writing stage (via import) dynamic binding of program objects is used in runtime mode. In my library, this is achieved by placing instructions in the code of constructors (or factory functions) for creating the dependencies they need, which are interpreted by the Object Container when the program is running and on the basis of which the necessary sources are loaded and the necessary dependencies are created.

In this article I have formulated the rules for creating these instructions and would like to know from the community how intuitive these rules are and whether they cover all use cases or if I have missed something.

Dependency Identifier Entity

Library functionality @teqfw/di made in the image and likeness RequireJSbut using the concept of “namespaces” from Zend 1 (My_Component_ClassName). So, in requirejs The required dependencies were described in the following form:

requirejs(['helper/util'], function (util) {
   // use the 'util' dep
});

IN @teqfw/di similar code looks like this:

function ({'App_Helper_Util': util}) {
   // use the 'util' dep
}

You can see that in both cases the dependency identifier is a string:

In other words, “dependency identifier” is a string containing information that the Object Container uses to find the source code of the required dependency and create a new object from it for subsequent injection into the constructed object.

File addressing

The principles of file addressing are taken entirely from Zend 1. Each loaded es6 module file (php script in Zend 1) is assigned a certain line, in which the path to the corresponding file relative to a certain starting point is reflected between separators (“_” – underscore).

For example:

App_Helper_Util => /home/alex/prj/app/src/Helper/Util.js

That is, if we are for the address App let's take the catalog as a starting point /home/alex/app/srcthen we can address the source code inside this directory:

Export Addressing

As I already noted above, @teqfw/di works only with es6 modules that describe objects accessible to other modules via es6 export. Requirejs and Zend1 have not encountered es6 export, so we can't go any further by analogy. But if we think logically, then we need some additional separator to distinguish the elements of the file path (directories and file name) from the export name in this file:

  • App_Helper_Util.format

  • App_Helper_Util#format

  • App_Helper_Util/format

Import type

In my practice, I have encountered the following types of dependencies:

  • dependency on es-module entirely

  • dependency on separate export in es module

  • dependency on the result of executing a separate export in the es module

Let me demonstrate this with examples:

// ./Lib.js
export class Service {}

Dependency on the entire es6 module:

import * as Lib from './Lib.js';
const Service = Lib.Service;

Dependence on individual exports:

import {Service} from './Lib.js';
const MyService = Service;

Dependency on the result of a separate export:

import {Service} from './Lib.js';
const service = new Service();

So, in the dependency identifier, you need to not only display the path to a separate es6 module and the name of the export inside the module, but also the form in which to use this export – as-is or as a factory to create a dependency.

I already touched on this topic a year ago and suggested encoding the type with special characters in the dependency identifier:

App_Helper_Util.format$A - as-is
App_Helper_Util.format$F - фабрика

But my experience over the past year has shown that this type of import coding is not popular even in my own code.

Single and Specimen

That's because I prefer to split my code into two big groups: data and functions. Data (DTO) describe the structure of the information being processed, and functions, accordingly, process this information. If you write code for handlers in a functional style, then a significant part of the runtime objects in the application will be singletons (including factories for creating DTO instances). In other words, 80% of my injected dependencies are singletons, and only 20% are separate instances and as-is (80/20 is not exact, just by eye).

The typical code for injecting a dependency in my applications is something like this:

export default class App_Service_Auth {
   constructor({App_Act_User_Read$: actRead}) {
       // ...
   }
}
  • Every es6 module in most cases (80%) uses a single default export.

  • The object container in most cases (80%) creates a single instance of this object (functional style!) and distributes it as a dependency to everyone who needs it.

The rules for creating object identifiers in JS allow only ” without quotesalphanumeric characters, underscores and the $ sign” Therefore, for the most common variant of the dependency description, I want to use the description without quotes.

The essence of the instructions of the most common form of identifier can be roughly revealed to the Object Container as follows:

  • App_Service_Auth$ – take default export from script “.../src/Service/Auth.js“, use it to create an object (inject all dependencies into it, if any), save this object as a singleton in your memory and inject it into all other objects where it is needed.

To describe that I want to get a new instance of an object as a dependency, I use a double sign $$but this option is even less common than simply importing the entire es6 module or using a separate named export as-is.

Preprocessing and postprocessing

Typically, dependency injection involves configuring the Object Container to pre-process input data (the dependency identifier) ​​and post-process output data (the injected dependency itself). Pre-processing the dependency identifier involves (in most cases I've encountered) replacing one identifier with another. For example, this is how interfaces are replaced by their implementations.

But the format of the dependency identifier is most likely influenced by post-processing. In my practice, the need for post-processing has been encountered in at least four variants:

  1. Adding the base object identifier to a new logger instance before injecting the logger. This allows the logger to add in messages who exactly is the source of the message.

  2. Wrapping a result object with another object to override or extend the functionality of the result object. In Magento, this functionality is called plugin/interceptor.

  3. Create a proxy object based on a dependency identifier that creates and returns the required dependency not in a constructor or factory function, but when accessing the proxy object. This functionality allows you to break circular dependencies in constructors.

  4. Create a factory based on the dependency identifier to produce new instances of the dependency and implement the factory itself as a dependency.

In the first case, it is enough to analyze the dependency identifier and perform additional actions on the injected object. The second case also does not affect the possible format of the dependency identifier. But the third (interceptor) and fourth (factory) cases are essentially the same and have an impact on the format. After all, it turns out that instead of returning the dependency specified in the identifier, the Object Container returns another object that to some extent depends on the object specified in the identifier. A possible solution is to specify the types of post-processors as an array:

  • App_User_Auth$(proxy,factory) – a dependency on a proxy object that, when first accessed, will return a factory that can create objects of type App_User_Auth.

This is not a very common scenario, but it should also be taken into account when choosing a dependency identifier format.

Dependency Identifier Structure

Thus, the dependency identifier string must be encoded the following information:

  • moduleName – path to the file with the source code (es6 module).

  • exportName – the name of the export that should be used to create the dependency.

  • composition – use export as-is for injection or as constructor/factory.

  • life – defines the life-style of the injected dependency (singleton or instance).

  • wrappers – list of decorators for post-processing.

The most common case when a singleton created from the default export of some es6 module is injected is App_Service_User$. This identifier can be written without quotes:

export default class App_Main {
   constructor(
       {
           App_Service_User$: srvUser, // singleton, factory, default export
       }
   ) {}
}

The most universal identifier is the implementation of the entire es6 module (App_Service_User):

export default class App_Main {
   constructor(
       {
           App_Service_User: ServiceUser, // es6 module as-is
       }
   ) {
       // import {create, read, update, drop} from './Service/User.js';
       const {create, read, update, drop} = ServiceUser; 
   }
}

You can also use a double one $$ to indicate that it is not a singleton that needs to be implemented, but a new instance created from the default export of the corresponding module:

App_Logger$$: logger

And that's where the good opportunities for using unquoted identifiers end.

To be able to specify a named export in a dependency identifier, you need another separator to “_” (directories on the path to the source code file) and “$” (singleton or instance), but the naming rules in JS do not provide for more separators without quotes.

As a separator of the export name from the path to the es6 module, I chose a dot (“.”) and got the following options for describing dependencies (all already with quotes):

  • 'App_Service_User.create' – use named export as-is.

  • 'App_Service_User.create$' – use named export as a factory function to create and inject a singleton.

  • 'App_Service_User.create$$' – use a named export as a factory function to create and inject a new instance.

Using postprocessing decorators, you can get exotic instances of dependency identifiers like these:

export default class App_Main {
   constructor(
       {
           'App_Service_User.create$$(proxy,factory)': factoryServiceUserCreate
       }
   ) { }
}

There is some awkwardness when constructing a dependency identifier that specifies that some es6 module should use the default export as is (as-is). If you use a period as a separator, it will not look very expressive visually:

'App_Service_User.': user

Or you need to use the longer version:

'App_Service_User.default': user

Proposed format for dependency identifier

In my library it is possible to use different formats of dependency identifier. Only the identifier structure is hardcoded (TeqFw_Di_DepId), and the packaging of this information into an identifier string can occur according to different rules (different separators, order of parts, etc.).

The object is responsible for parsing the identifier string and recreating the identifier structure. TeqFw_Di_Container_Parserwhich is a set of parsers and can apply different identifier decoding schemes (each parser in the set must implement the interface TeqFw_Di_Api_Container_Parser_Chunk).

I think this approach is redundant in stable conditions, but in conditions where my rules for constructing the identifier varied slightly from project to project, it was quite justified. It allowed me to use the same library with different encoding formats of dependency identifiers.

At this point I have a pretty good idea of ​​what I expect from the identifier, and I want to set the following as the default format:

  • App_Service – es6 module as-is

  • 'App_Service.default' or 'App_Service.' – default-export as-is

  • 'App_Service.name' – named service as-is

  • App_Service$, 'App_Service.default$' – creating a singleton object from default export

  • 'App_Service.name$' – create a singleton object from a named export

  • App_Service$$, 'App_Service.default$$' – creating an instance of an object from a default export

  • 'App_Service.name$$' – create an instance of an object from a named export

  • '…(proxy,factory)' – adding decorators to the embedded object at the post-processing stage (the names of the decorators are defined in the post-processors used by the application)

In most cases the option will be used App_Service$ (singleton), variant App_Service allows you to implement an entire es6 module, similar to static import, but dynamically (this is where the implementation of interfaces is enabled through pre-processing by replacing interfaces of the type Plugin_Api_IService for their implementation App_Service_Impl in the Object Container configuration). The rest – as needed.

I would like to know from colleagues who have experience working with IoC in JS/TS and/or other programming languages ​​what are the pros and cons of my approach and how intuitive is the proposed format for the dependency identifier. Write your feedback in the comments if you are interested in this topic. Or at least take part in the survey 🙂

Thanks for reading and feedback.

Retrospective

A retrospective of my publications on this topic, in case it suddenly seems to someone that I “толку воду в ступе“When reading, you can see how my understanding of the essence of the issue has gradually changed over the course of five years.

Similar Posts

Leave a Reply

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