Dependency Injection
Introduction
Tramway uses Dependency Injection Pattern to maximize reuse of classes written within the application. Tramway itself is a collection of reusable classes arranged in different groups based on use case.
If you're new to the dependency inversion principle, it's the idea that a class or service can exist independently of its dependencies as long as the dependencies it requires follows a clear interface. In Javascript, this is more of a convention than an enforcement like in other languages.
In laymen's terms, imagine your service gets some information from a database, formats it and injects some business logic and then returns that information to a client.
Without using Dependency Injection, the service would likely know how to get the data from the database, or make a static call to a service that does. The service would also know which factory to use to build the object and so on. At first glance, this seems reasonable since the service needs to do all these things anyway. Imagine that you build another application that does the same thing, except there are minor differences. The dilemma of this design is within the details. A lot of logic will have to be rewritten to adapt to a new use case for a similar scenario because the database might be different, or the objects in question might be different but the service itself does the same thing at a glance.
import SpecificDatabase from './SpecificDatabase';import SpecificFactory from './SpecificFactory';class Service {async getAndTransform() {let items = await SpecificDatabase.get();return items.map(item => SpecificFactory.create(item));}}//Usage:let service = new Service()let info = await service.getAndTransform()
Using Dependency Injection, your service can still orchestrate what it needs to orchestrate, however, it will now demand that it's companion services be given to it instead of going to find these itself.
class Service {constructor(database, factory) {this.database = database;this.factory = factory;}async getAndTransform() {let items = await this.database.get();return items.map(item => this.factory.create(item));}}//Usage:let service = new Service(new Database(), new Factory())let info = await service.getAndTransform()
Configuration-based Dependency Injection
There are clear benefits to Dependency Injection but in a large application it can be difficult to manage because each dependency would need to be recreated each time you need to use it. The introductory example largely oversimplifies the reality of setting up a service and is meant to introduce the concept.
Tramway has a library for dealing with dependency injection: tramway-core-dependency-injector
The library allows a static configuration of wiring to be used to build the application dynamically and manage dependencies for you. It is inspired largely from the way Symfony handles dependency injection. Parameters consist of constants that are used within the application and Services consist of configuration of class instantiation. These mappings are declared in configuration files and used at boot to build the application.
Every Tramway application uses the root src/index.js
file as the entrypoint to instantiation:
import { DependencyResolver } from 'tramway-core-dependency-injector';import * as parameters from './config/parameters';import services from './config/services';DependencyResolver.initialize(services, parameters);let app = DependencyResolver.getService('app');app.initialize().start();
At boot, the DependencyResolver receives the consolidated configuration of services and parameters from the application's config folder and saves them internally as a map. The app
is initially retrieved by requesting it from the DependencyResolver
which will look or build it and its dependencies internally and then return it to use.
The lifecycle of dependency injection within Tramway is as follows:
- All configuration is saved into respective Parameter and Service containers.
- When a service is referenced by label and is executed, it will try to get existing instances of its dependencies from the Instances container.
- If the instance already exists, it is returned, however, if not, it gets built by the DependencyInjector. Parameters are injected first and then dependencies are recursively built, saved to the Instances container and then assigned to the service that's needed.
Given that the framework only initializes dependencies when they are required, this can be loosly referred to as lazy-loading services.
Usage
Tramway leverages the NodeJS module system for inheritence and resolution, but also to support breaking configuration into multiple files. Having multiple files and subfolders for configuration helps keep it organized and makes it easy to copy configuration to other projects.
Config Schema
The config declaration object always contains type
and key
keys which correspond to the type of value to look for and the name used to reference it inside the Dependency Resolver's various containers.
Parameters
Parameters consist of constants your application will use, like database configuration.
Take the following mysql configuration for example:
It is declared in src/config/parameters/global/mysql.js
const {MYSQL_DATABASE,MYSQL_USER,MYSQL_PASSWORD,MYSQL_PORT,MYSQL_HOST,} = process.env;export default {"host": MYSQL_HOST,"port": MYSQL_PORT,"username": MYSQL_USER,"password": MYSQL_PASSWORD,"database": MYSQL_DATABASE,};
The configuration is then declared as the mysql
key implicitly that will be used within dependencies later on and is stored in the root src/config/parameters/global/index.js
import mysql from './mysql';export {mysql,}
This mysql config object can then be referenced using the following json object within service configuration:
{"type": "parameter", "key": "mysql"}
Albeit an obsolete concept, Tramway does support declaring environment-specific parameters which will inherit and override those in global. The name of the folder in src/config/parameters
corresponds to the environment you set to the NODE_ENV
variable. The reason this is largely obsolete is environment variables can be used for the same purpose and mapped within the configuration as shown above.
Services
Services are class declarations and treats all types of classes the same.
Like with parameters, the entrypoint of services is its respective index.js
file which acts as a consolidation point for multiple files. Like with parameters, service declarations can also be organized within multiple files to make it easier to find them. Thanks to the spread operator, keys in a given file will be merged with files to form one consolidated configuration object.
Here's a sample src/config/services/index.js
file:
import router from './router';import logger from './logger';import core from './core';import controllers from './controllers';import factories from './factories';import repositories from './repositories';import services from './services';import providers from './providers';import events from './events';import subscribers from './subscribers';export default {...router,...logger,...core,...controllers,...factories,...repositories,...services,...providers,...events,...subscribers,}
Now we can use the MySQL example to build a provider, which is a class we can import from another library and plug in with the src/config/services/providers.js
file:
import MySQLProvider from 'tramway-connection-mysql';export default {"provider.mysql": {"class": MySQLProvider,"constructor": [{ "type": "parameter", "key": "mysql" }],"functions": []},};
At runtime, the MySQLProvider config above will be the equivalent of declaring the class as a mapped singleton wherein the code would look like this:
instancesMap.set('provider.mysql',new MySQLProvider({"host": MYSQL_HOST,"port": MYSQL_PORT,"username": MYSQL_USER,"password": MYSQL_PASSWORD,"database": MYSQL_DATABASE,}))
This service can then be referenced with the following JSON object:
{"type": "service", "key": "provider.mysql"}
When declaring a class, there are a few key elements:
The name of the service is the key of the configuration entry provider.mysql
. By convention, the type of service is prepended to the name and concatenated to the name of the service using a .
. This helps to normalize configuration which aids when debugging.
The class of the service is the imported class itself. If not from an external library, from your own codebase, the class you declare needs to be imported to the config.
The constructor takes an array of configurations in the order they will be assigned to variables when constructing the object. The dependency resolver will replace these JSON objects with the appropriate services at runtime.
The functions allow a service to execute an internal function once it has been built. The object structure it takes looks like this:
{"function": "use","args": [{ "type": "parameter", "key": "_method" }],},
The function key takes the name of the function and the args takes an array of configuraations to map corresponding to the order of arguments on the function.