Software Engineering: A Modern Approach
1 What is Dependency Injection?
1.1 Introduction
Dependency Injection is a design pattern that is not covered in the GoF book. However, the solution is commonly employed in practice and supported by many frameworks.
The concept of Dependency Injection is quite simple and essentially a literal application of its name. Let’s break it down into three steps:
1. Suppose a class PaymentService
, which depends on an
interface PaymentProcessor
:
class PaymentService {
private PaymentProcessor processor;
...
}
2. However, to follow the idea of the pattern, this class should not
refer to classes that implement the PaymentProcessor
interface, as in:
class PaymentService {
private PaymentProcessor processor = new PayPalPaymentProcessor();
...
}
The issue lies in the syntactical coupling of this code with a
particular payment processor implementation
(PayPalPaymentProcessor
). In other words, the class is not
prepared to accommodate changes in this dependency.
3. Therefore, a preferable solution is for
PaymentService
to receive this dependency via its
constructor:
class PaymentService {
private PaymentProcessor processor;
PaymentService(PaymentProcessor processor) { // dependency injection via constructor
this.processor = processor;
}
...
}
or receive the dependency through a setter:
class PaymentService {
private PaymentProcessor processor;
...
void setPayment(PaymentProcessor processor) { // dependency injection via setter
this.processor = processor;
}
...
}
Therefore, it is now easy to understand the pattern’s name: the dependencies of a class are injected into it through parameters of either a constructor or a setter method.
Thus, the advantages of Dependency Injection are:
Dependency Injection simplifies the process of changing the dependencies used by a class. In our example,
PaymentService
is a class for processing payments, which currently uses aPayPalPaymentProcessor
for this task. However, tomorrow we might decide to handle payments with aStripePaymentProcessor
class instead. In such a scenario, both classes—PayPalPaymentProcessor
andStripePaymentProcessor
—just need to implement thePaymentProcessor
interface.Dependency Injection facilitates the testing of
PaymentService
since we can easily mock thePaymentProcessor
dependency. For instance, instead of using a real payment service we can use a mock service that simulates payments. Again, this service simply needs to implement thePaymentProcessor
interface. If you’re unfamiliar with the concept of mocks, please refer to the section on the subject from Chapter 8.
1.2 Dependency Injection Frameworks
One drawback of Dependency Injection is that the responsibility to create the dependencies—that is, to instantiate the objects used by a given class—is transferred to its clients, as illustrated in the following example:
class Main {
void main() {
PaymentProcessor processor = new PayPalPaymentProcessor();
PaymentService service = new PaymentService(processor);
...
}
...
}
However, there are frameworks that alleviate this extra burden for clients. Essentially, they assume responsibility for creating the required dependencies and injecting them into the target classes.
Generally, this works as follows:
class PaymentService {
private PaymentProcessor processor;
@Inject // annotation provided by the framework
PaymentService(PaymentProcessor processor) {
this.processor = processor;
}
...
}
The @Inject
annotation indicates that
PaymentService
intends to use Dependency Injection.
Consequently, the dependencies specified in its constructor should be
created and injected by the dependency injection framework.
However, we should specify the classes that need to be instantiated
whenever the framework calls a constructor annotated with
@Inject
. This can be achieved through a JSON file, as in
the following example:
{ "dependencies": {
"PaymentProcessor": {
"class": "PayPalPaymentProcessor"
}
...
}
This configuration file defines that instances of
PayPalPaymentProcessor
should be created and injected into
all classes that depend on PaymentProcessor
.
Finally, to instantiate a class that uses an @Inject
annotation, we no longer use the new
operator. Instead, we
call a method from the framework, as showed below:
class Main {
void main() {
PaymentService service = DIF.getInstance(PaymentService.class);
...
}
}
In this code, getInstance
is a method responsible for
instantiating classes that use Dependency Injection. Thus, our
hypothetical dependency injection framework (DIF) comprehends that:
The class
PaymentService
relies on dependency injection, as its constructor is annotated with@Inject
.Then, before instantiating an object of this class, the framework should instantiate the dependencies required by the class constructor. In our case, these dependencies must implement the
PaymentProcessor
interface.However, which class that implements
PaymentProcessor
should be instantiated? For this, the framework consults the configuration file and discovers thatPaymentProcessor
is mapped toPayPalPaymentProcessor
, as we showed earlier.Consequently, the framework instantiates an object of type
PayPalPaymentProcessor
and an object of typePaymentService
. While instantiating this latter object, it passes thePayPalPaymentProcessor
object as a parameter to its constructor.
The explanation provided above is based on Guice, a Dependency Injection Framework for Java developed by Google. However, there are other frameworks for this purpose, both for Java and for other programming languages.
Exercises
1. As described in Chapter 6, design patterns are categorized into creational, structural, and behavioral patterns. Within which of these categories would you place Dependency Injection? Please provide a brief justification for your answer.
2. Dependency Injection is often compared to the Factory design pattern. What is the disadvantage of configuring dependencies using factories? To answer, compare the following codes:
class A {
IB b;
A(IB b) {
this.b = b; // dependency injection
}
}
class A {
IB b;
A() {
this.b = IB_Factory.getInstance(); // factory
}
}
3. What is the relationship between Dependency Injection (design pattern) and Dependency Inversion (design principle)? To learn more about Dependency Inversion, see Chapter 5.
4. Why is it often mentioned that Dependency Injection can compromise the Information Hiding property? To learn more about this property, see also Chapter 5.