If you've ever used Angular for any amount of time, you've probably noticed how freaking awesome its dependency injection is. With just the invocation of the injectable decorator, you can pull in reusable instances of any service in your application just by referencing the type:
import {Injectable} from '@angular/core';
import {BackendService} from './backend.service';
@Injectable()
export class AuthService {
constructor(
private backend: BackendService,
) {
this.backend.somethingAwesome();
}
}
Notice that I don't have to instantiate the BackendService
anywhere because it's already handled for me by Angular. This has the added benefit of ensuring that only one instance of the BackendService
is created during runtime, which is good for memory!
Background - Flitter
For the past year or so, I've been creating an Express-backed JavaScript web-app framework called Flitter. One of the major philosophies in Flitter is that everything should be a class. So, while Flitter incorporates many traditionally ES5 libraries -- Express, Mongoose, Agenda.js, &c -- Flitter provides a system for defining their resources using ES6+ classes. That's why in Flitter you'll never see, for example, a schema based model definition:
// Mongoose - from the getting started guide
var kittySchema = new mongoose.Schema({
name: String
});
kittySchema.methods.speak = function () {
var greeting = this.name
? "Meow name is " + this.name
: "I don't have a name";
console.log(greeting);
}
Instead, we define the appropriate class:
// Using Flitter classes
const Model = require('libflitter/database/Model')
class Kitty extends Model {
static get __context() {
return {
name: String
}
}
speak() {
const greeting = this.name
? `Meow name is ${this.name}` : `I don't have a name`
console.log(greeting)
}
}
Having all our resources defined in standard classes is, subjectively, much easier to maintain and reason around than a multitude of different schema formats and function calls. Plus, it has the added benefit of enabling inheritance for all our resources out-of-the-box. But, it raises an interesting issue.
The Problem With Classier Services
If everything is a class, how do we access reusable methods from our application? If we were using objects, this would be easy:
// logHelpers.js
module.exports = exports = {
loggingLevel: 2,
out(what, level) {
if ( !Array.isArray(what) ) what = [what]
if ( level >= this.loggingLevel ) console.log(...what)
},
error(what, level = 0) { this.out(what, level) },
warn(what, level = 1) { this.out(what, level) },
info(what, level = 2) { this.out(what, level) },
debug(what, level = 3) { this.out(what, level) },
}
Seemingly, this is really easy to use. We just import the module and we're good to go:
const logging = require('./logHelpers')
const someFunction = () => {
logging.info('someFunction has executed!')
}
But what happens if we want to create a different helper "class" that will send e-mails if an error is logged? Well, we could just write the whole thing from scratch again, but that's not very DRY. So instead, we override specific properties from the original helper:
// emailLogHelpers.js
const logHelpers = require('./logHelpers')
module.exports = exports = Object.assign(logHelpers, {
emailOut(what, level) {
sendAnImaginaryEmailSomewhere(what);
this.out(what, level);
},
error(what, level = 0) { this.emailOut(what, level) },
})
But, this introduces ambiguity. What does this
refer to in error()
? What about in emailOut()
? Can you spot the error? It feels correct from an OOP standpoint. However, if we call error()
on the email helpers object, we will get the following error:
ReferenceError: out is not defined
at Object.emailLogHelpers (emailLogHelpers.js:6:4)
Why? Because this
in the emailOut
function doesn't actually refer to the combined object, but the original object at the time of creation. That is, the right-side of the Object.assign
call.
This could easily be resolved by defining a LogHelper
class and creating a child class EmailLogHelper
. Then, in the EmailLogHelper
class, this
would unambiguously refer to the instance itself, which already has all the LogHelper
methods. For example:
// EmailLogHelper.js
const LogHelper = require('./LogHelper')
class EmailLogHelper extends LogHelper {
out(what, level) {
sendAnImaginaryEmailSomewhere(what);
super.out(what, level);
}
}
But, this leaves us with the root issue of class-based services:
Service classes must be instantiated before they can be used.
Why is this a problem? Well, what if there's some config service that LogHelper
relies on (or depends on...) to get the logging level? Well, then to use the service anywhere, we'd also have to instantiate the config service and pass that in to the log helper. But, then we have 20 nearly-identical instances of the same class doing the same thing. So what's the solution?
Dependency Injection in ES6: Easier than you think!
The solution to all the problems above is to have some dependency injector manager class create instances of all the relevant services when the application starts. Then, whenever a class needs access to a service, it fetches the shared instance from the DI. This saves memory, and prevents the manual dependency-chaining problem above.
It turns out that, because of the niceness of the ES6 class syntax, it's pretty easy to implement a basic DI in vanilla JavaScript! We're going to approach this in multiple parts.
The Service Class
A service (for our basic purposes) is just a class that should be instantiated once the first time it is needed, then re-used for subsequent calls. So, the service class can be entirely bare for now:
class Service {
}
Eventually, you can make this system more advanced by doing fancy things like tracking service state or even making services themselves injectable! Perhaps in a future post we'll explore this.
The Injectable Class
This class will be the parent class of every class that can make use of automatic DI. In our Angular analogy, this is akin to the Injectable decorator. It should do 2 things: specify the services we want access to, and provide a mechanism for accessing them. Here's an example based on Flitter's Injectable class:
// Injectable.js
class Injectable {
static services = []
static __inject(container) {
this.services.forEach(serviceName => {
this.prototype[serviceName] = container.getService(serviceName)
}
}
}
Obviously, this is missing some niceties and type-checking, but the basic functionality is there. Statically, we define an array of service names for the instance to access, then, at some point before the class is instantiated, the __inject
method is called. This method injects the services instances into the class' prototype
, which is the under-the-hood function that is copied for each instance of the class. It gets these service instances from some magical container
which we'll cover shortly.
This makes it really easy for classes to access services. For example:
// DarkSideHelpers.js
const Injectable = require('./Injectable')
class DarkSideHelpers extends Injectable {
static services = ['logging']
doItAnakin() {
try {
somethingDangerous()
} catch (error) {
this.logging.error('It\'s not the Jedi way!')
}
}
}
No instantiation of the LogHelper
class required! Isn't that nifty?
The Service Container
But, fancy static will do us nothing if there's no services to inject. So, we need to create a place for them to live. Because we want to reuse instances as much as possible, we need a container to create and manage those instances for us. This container should: contain a mapping of service names to service classes, instantiate requested services if they don't exist yet, and return these instances on request. Let's try one:
// ServiceContainer.js
const LogHelper = require('./LogHelper')
const EmailLogHelper = require('./EmailLogHelper')
class ServiceContainer {
constructor() {
// We define the service classes here, but we won't
// instantiate them until they're needed.
this.definitions = {
logging: LogHelper,
emailLogging: EmailLogHelper,
}
// This is where the container will store service instances
// so they can be reused when requested.
this.instances = {}
}
getService(serviceName) {
// Create a service instance if one doesn't already exist.
if ( !this.instances[serviceName] ) {
const ServiceClass = this.definitions[serviceName]
this.instances[serviceName] = new ServiceClass()
}
return this.instances[serviceName]
}
}
Our bare-bones service container contains a list of service definitions, and a method for retrieving service instances. It satisfies our requirements because it won't instantiate a service until it's needed, but will reuse existing instances. So, we have the container for our services, but now we the final piece to make all three parts work together.
The Dependency Injector Host
The DI host is the boss of the whole operation. It's responsible for creating an instance of the container (or even multiple different instances) and keeping track of them. It is also responsible for calling the magic __inject
method on classes before they're instantiated. There are several different strategies for how this can be done. Each has its own merit based on the situation, but we'll look at one that works well for standalone applications:
// DependencyInjector.js
const ServiceContainer = require('./ServiceContainer.js')
class DependencyInjector {
constructor() {
this.container = new ServiceContainer()
}
// Injects the dependencies into an uninstantiated class
make(Class) {
return Class.__inject(this.container)
}
}
Like the rest of our classes, this one is pretty straightforward. When constructed, it creates a new service container. Then, we can pass in classes to the make
method and it will inject dependencies from the container into the class.
The DI instance should be shared across your entire application. This will help re-use service instances as much as possible. Here's a silly example:
A Spam-Generating Example App
As an example of how to use this system, let's create a very basic application. This application should, on run, repeatedly send spam email to its owners. We're going to use our fancy dependency injector for this.
// App.js
const Injectable = require('./Injectable')
class App extends Injectable {
static services = ['emailLogging']
run() {
setInterval(() => {
this.emailLogging.error('Haha made ya\' look!')
}, 5000)
}
}
Now, we'll tie it all together by using our DI to run the app:
// index.js
// Create the dependency injector instance for the application
const DI = require('./DependencyInjector')
const di = new DI()
// Now, create the instance of our application using the DI to inject services
const App = di.make(require('./App'))
const app = new App() // Has access to the emailLogging service
app.run();
Conclusion
Dependency injection is a powerful tool ... for reducing code complexity and properly sub-dividing your code into pure, maintainable bits.
We have built a basic application with injectable dependencies in vanilla ES6. Obviously, there are a lot of enhancements and improvements that could be made here. For example, type checking for services and containers, making the services themselves injectable, moving the Injectable
base class to an ES7 decorator so it can be applied to classes with other parents, &c. Perhaps I'll do a follow-up in the future. But, I hope this article was a good illustration of the power of DI in making code nicer. Dependency injection is a powerful tool not just for reducing memory load, but also for reducing code complexity and properly sub-dividing your code into pure, maintainable bits. Thanks for making it this far, and if you have any questions, be sure to comment below.
Garrett
P.S. - You can find the code used in this example here.