The first two parts of this series explored the differences between declarative and imperative code and how declarative code facilitates data-binding. The previous article explored how data-binding provides the foundation for powerful design patterns. This article completes the series by explaining dependency injection and illustrating how important the concept is to modern web development.
Dependency injection provides a solution for inversion of control (IoC). The easiest way to understand IoC is through an example, which we'll explore.
Modern web applications are composed of multiple components. It is not uncommon for enterprise applications to have thousands of interacting components maintained by dozens of globally distributed developers. When controls explicitly create instances of their own dependencies it is referred to as "tight coupling." Each component controls its own dependencies and is tightly coupled to them because changes create a ripple effect throughout the code base.
Two common concerns in web-based applications are generating logs and displaying dialogs. A tightly coupled component might address these concerns like this:
(function () {
var response = prompt('Are you sure?');
console.log(response);
})();
Although this may work well for a small application, it creates a problem in larger applications. At some point the business may decide that the built-in browser prompt is not good enough and ask to switch to a modal HTML-based dialog that can be styled. To do this, developers will have to search through the entire code base, find all references to the "prompt" call and then replace each with the custom code. This is highly inefficient.
Similar problems would occur if the business decides that logs should be sent to a web service for archival on a server rather than simply emitted to the local browser console. Solving this problem requires an inversion of control. Instead of allowing the component to be in control, the control is inverted.
There are several ways to invert control. Consider this example:
function MyControl() {
var _that = this;
this.getAndLogAnswer = function () {
var response = _that.getAnswer('Are you sure?');
_that.log(response);
}
}
var myControl = new MyControl();
myControl.getAnswer = function (msg) {
return prompt(msg);
};
myControl.log = function (msg) {
console.log(msg);
};
myControl.getAndLogAnswer();
In the previous example, the component expects the getAnswer
and log
methods to exist, but does not control how they are set. Instead, the outer app sets these up. This is inversion of control as the app is given control over setting up the dependencies.
Here is another approach:
(function(dialog, logger) {
function MyControl() {
this.getAndLogAnswer = function () {
var response = dialog('What is your name?');
logger('The name is ' + response);
};
}
var ctrl = new MyControl();
ctrl.getAndLogAnswer();
})(
function(msg) {
return prompt(msg);
},
function(msg) {
console.log(msg);
});
The second example takes a different approach and injects the necessary components into the immediately-invoked function expression (IIFE).
Dependency Injection is a simple solution to inversion of control that involves injecting the dependencies into the components that require them. There are many reasons why this is beneficial to modern web applications.
Dependencies can be complex. For example, if A depends on B and B depends on C, without dependency injection you create C from B and B from A and then reference A from a dozen different places. What happens when C requires something? Or what if B requires D in addition to C? Without dependency injection, you end up with a lot of refactoring, finding all the places that you've created those components. With dependency injection, A only has to worry about B. A doesn't care what B depends on because that is handled by the dependency injection solution.
Timing is often an issue. What happens when A depends on B but the script for B is loaded after A? Dependency injection removes that concern because you can defer or lazy load the dependency when it's time. In other words, regardless of how A and B are loaded, you only need to wire them up when you first reference A. On large projects that have 50 JavaScript components referenced from a single page, the last thing you want to have to worry about is including them in the correct order. Modern bundling systems can also work with dependency injection to produce single, optimized JavaScript files that load all of the components at once.
Dependency injection is critical for testing. If A directly instantiates B then you're stuck with the implementation of B that A chooses. If B is injected into A, you can create a "mock B" or a "stub B" for testing purposes. For example, you might have a component that depends on a web service. With dependency injection, you can create an implementation that uses hard-coded JSON for testing purposes and then wire in the real component that makes the service call at runtime. Most modern frameworks have a facility for substituting mocked components or even creating them on the fly.
Single Responsibility – Dependency Injection is actually one of the SOLID principles. It helps to encourage the notion of designing components with a single responsibility. You want to be able to focus on one thing in a component and should only have to change it for one reason. This is like building padding around your component that insulates the rest of the system from changes. If the component is responsible for boiling water and mixing shakes, any change will force you to test both scenarios. Having a single responsibility means you can change how you boil water without worrying about making shakes because another component is responsible for that. This makes it easier to maintain the code.
It's great for managing large teams. Here's something else to think about. Forget the technology, let's talk about teams. When you have a lot of cooks in the kitchen, it is easy for them to bump elbows and step on each other's toes. Having nice, isolated components means more team members can work in parallel without having to worry about what the other person is doing.
To illustrate how dependency injection works, I wrote a simple tool named jsInject. The tool provides what is referred to as a "container" or a simple component that keeps track of dependencies and serves as a factory to instantiate components when your app needs them. Containers hold the instances of the components with their dependencies resolved. You can view the source code and run the tests to see how it works.
In a nutshell, the basic algorithm has two parts. The first part allows registration of a component with a name. This is done to survive minification when the names might be changed to compact the code. The second part resolves dependencies when a component is requested.
The resolution happens recursively. When a component is requested, the list of dependencies is inspected. If a dependency is already created, it is passed into the component's constructor or injected. If it is not created, that child component is requested and goes through its own resolution cycle that eventually results in an instance being returned that is saved for later and passed into the constructor function.
Many modern frameworks such as Angular 2 have a very mature DI implementation. Dependencies aren't just about how components relate, but also how JavaScript is loaded. Many apps leverage dynamic module loaders, or libraries that are smart enough to only pull in dependencies as they are needed. This can reduce initial load times and also conserve browser memory because only the aspects of an application that the user needs are present. In fact, the latest version of JavaScript (ECMAScript 2015) has its own specification for module loading.
Another benefit of dependency injection is the ability to traverse dependencies and discard code that isn't needed. A popular tool called WebPack is able to implement tree-shaking. This is a reference to the graph of dependencies visualized as a tree with the leaves being components that have no dependencies on their own. In large, complex web apps, the bundling tool is able to traverse the tree and "shake loose" leaves that aren't used. This eliminates "dead code" and results in an optimized bundle that only loads what is needed by the application.
It is important to note that dependency injection isn't the only way to resolve dependencies across components. Another approach is to use what is referred to as decorators. Decorators are a way of annotating code to provide metadata. The metadata can then be used to apply behaviors or aspects.
An aspect is a cross-cutting concern that isn't a true dependency. For example, consider the following class in TypeScript:
class Person {
constructor(public firstName: string, public lastName: string) {
}
showFullNameWithPrefix(prefix: string): string {
return prefix + " " + this.firstName + " " + this.lastName;
}
}
It is instantiated with a first and last name, then exposes a method that takes a prefix like "Mr." and returns a fully formatted string. Although this example is simple, consider what would happen if it became part of a larger enterprise application. You'll probably want to include logging in the app to make it easier to troubleshoot. Logging will make it easier to see what happened without having to debug the entire app when it doesn't behave as expected.
In a traditional approach, you might decide to create a logging component, then add a dependency to that component to the Person
class. Although this would work, the dependency isn't a true dependency - it's one that is imposed by the cross-cutting concern of logging. This is a concern that all of your components have, and therefore is an aspect of the system.
With the decorator approach, you could simply annotate that the method should use logging:
@log
showFullNameWithPrefix(prefix: string): string {
return prefix + " " + this.firstName + " " + this.lastName;
}
That aspect is then implemented elsewhere. In TypeScript, you write code for the aspect like this:
function log(target: Object, propertyKey: string, descriptor: TypedPropertyDescriptor<any>) {
// save a reference to the original method
var originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
// pre
console.log("The method args are: " + JSON.stringify(args));
// run and store the result
var result = originalMethod.apply(this, args);
// post
console.log("The return value is: " + result);
// return the result of the original method
return result;
};
return descriptor;
}
This implementation "intercepts" the method and automatically logs the parameters that are passed in and the result of the method. The full code for this example is available in my TypeScript Presentation GitHub project. That was a very simple example, but you can probably imagine more complex scenarios such as being able to authorize access to a method, prompt before changes, and others - all by simply annotating the methods that require those aspects or behaviors.
Dependency Injection is the final critical feature that I believe has enabled the proliferation of modern web development. In fact, it is one of the main features that attracted me to Angular when it was in beta. Our large team of over 20 developers experienced an immediate increase in velocity of delivery of code and improvement in quality when we implemented a framework that addressed all "three Ds of modern web development": declarative syntax, data-binding, and dependency injection.
According to recent surveys based on jobs in the market and developer interests, JavaScript is the most used and in-demand language. Modern web apps are here to stay and the days of declaring the web, or more specifically JavaScript, as "unfit for the enterprise" are far gone. Today's web apps benefit from productive patterns, practices, and implementations that make large applications practical and facilitate scaling large teams.
The three D's are just a few of the reasons why JavaScript development drives so many consumer and enterprise experiences today. Although the main point of this series was to demonstrate the maturity of front-end development and the reason why JavaScript development at enterprise scale is both relevant and feasible today, it is also the answer to a question I often receive. "Why use Angular 2 and TypeScript?" My answer is this: together, Angular and TypeScript provide a modern, fast, and practical implementation of the three D's of modern web development.
Related resources
Jeremy is a senior cloud developer advocate for Azure at Microsoft and a former 8-year Microsoft MVP. Jeremy is an experienced entrepreneur and technology executive who has successfully helped ship commercial enterprise software for 20 years. You can read more from him at his blog or find him on GitHub.