About Dependency Injection
Still wrapping your head around DI? Let's explore the philosophy and functionality behind it, then see how Guice, or any other such framework, can help.
Join the DZone community and get the full member experience.
Join For FreeWhen I first heard about Dependency Injection at my first workplace, it sounded very fancy to me, and, somehow, a bit scary. And, in combination with Spring, it looked like black magic. But after reading a bit about it, I think this expression "dependency injection is a 25-dollar term for a 5-cent concept" describes it perfectly. I don’t want to say that it is a cheap pattern: It is not! It is extremely helpful and a must-have in software development. But it is simple, and its simplicity makes this pattern simply brilliant.
So What Is It?
Let’s see a simple example to understand better what this means. Let’s build a simple payment service for an online shop. We have a cart with a list of products, and we create a method that iterates all the products and uses a cash payment service to pay for them.
public class CashPaymentService {
public void pay(Product product) {
System.out.println(“Payed with cash“);
}
}
public class Cart {
private List < Product > productList = new ArrayList < > ();
private CashPaymentService paymentService = new CashPaymentService();
public addProductToCart(Product product) {
productList.add(product);
}
public void buy() {
productList.stream().forEach(paymentService::pay);
}
}
This works.
But why should the Cart be responsible for creating the CashPaymentService? The Cart is tightly coupled to the CashPaymentService object, and there are several problems with this. What if we want to use another payment service? Maybe we want to add a service for paying with a credit card. Then, in this case, we would have to change the implementation of the Cart object to use another payment service — and this is not Cart’s responsibility. Or maybe we want to have a mock payment service for testing the Cart object. Then we should change the Cart object again to use a mocked payment service in a test environment.
The problem here is the combination of concerns in the Cart object. The Cart should do just what it is meant to do. It holds a list of products and offers a method for buying them. The payment service is an external dependency here. This service is used by the Cart, but it is not Cart’s responsibility to create it. The Cart should just receive this dependency. There are multiple ways to do this, and the next code snippet shows the simplest method. We send the dependency through a constructor. This is Dependency Injection in its simplest form.
public class Cart {
private List < Product > productList = new ArrayList < > ();
private CashPaymentService paymentService;
public Cart(CashPaymentService paymentService) {
this.paymentService = paymentService;
}
public addProductToCart(Product product) {
productList.add(product);
}
public void buy() {
productList.stream().forEach(paymentService::pay);
}
}
The Cart object needs a payment service. It is not responsible to create it. It is just an external dependency that it must receive, so it receives it when the object is instantiated through its constructor. You also can use setter methods — doesn’t matter. The point of Dependency Injection is just to inject the dependency and not to make a class responsible for creating or looking up to its dependencies. This way, with the DI pattern, we achieve separation of concerns.
Improvements
This does not resolve the problem that the Cart object is tightly coupled to the CashPaymentService. Even if the CashPaymentService is injected, the Cart is strongly tied to the CashPaymentService implementation. The solution here is simple, and we know it from the strategy pattern. The key of the strategy pattern that we'll use here is to program to an interface, not an implementation. So we need to create a PaymentService interface that has only one method: pay(). This is the only thing that the Cart must know about the PaymentService.
public interface PaymentService {
void pay(Product product);
}
public class Cart {
private List < Product > productList = new ArrayList < > ();
private PaymentService paymentService;
public Cart(PaymentService paymentService) {
this.paymentService = paymentService;
}
public addProductToCart(Product product) {
productList.add(product);
}
public void buy() {
productList.stream().forEach(paymentService::pay);
}
}
Now the Cart is not tied to the CashPaymentService implementation, so we are free to inject whatever PaymentService implementation we need.
public class CashPaymentService implements PaymentService {
@Override
public void pay(Product product) {
System.out.println(“Payed with cash“);
}
}
public class CardPaymentService implements PaymentService {
@Override
public void pay(Product product) {
System.out.println(“Payed with credit card“);
}
}
public class TestPaymentService implements PaymentService {
@Override
public void pay(Product product) {
System.out.println(“Test payment“);
}
}
We have multiple ways of injecting the desired dependency when we instantiate the Cart object. Let’s see an example using the Factory Pattern.
public class PaymentServiceFactory {
public PaymentService getPaymentService(String paymentType) {
if (paymentType == null) {
return null;
}
if (paymentType.equalsIgnoreCase(“CASH”) {
return new CashPaymnetService();
}
if (paymentType.equalsIgnoreCase(“CARD”) {
return new CardPaymnetService();
}
if (paymentType.equalsIgnoreCase(“TEST”) {
return new TestPaymnetService();
}
return null;
}
}
Now, we can use this factory class to build whatever payment service we want.
public class Demo {
public static void main(String[] args) {
PaymentService paymentService = new PaymentService(“CARD”);
Cart cart = new Cart(paymentService);
}
}
What did we accomplish here? Using Dependency Injection, the Cart object receives its dependencies. Using the strategy pattern, we decouple the Cart from a specific payment service, and we can use whatever payment service we desire. Using the factory pattern, we can specify, with a string parameter, which payment service we want, and the factory class is responsible for creating it. The code now is loosely coupled and clearly looks better than before.
So Why All the Frameworks?
Using the factory pattern, the dependencies are hidden in the code. If you want to add a new implementation, you have to modify the factory class. Moreover, using the factory pattern, we have to call the “new” keyword. It’s our responsibility to instantiate the objects. For now, that does not sound so bad, but what if we have a large project with hundreds of classes, and all of them have all kinds of dependencies to each other? Well, then our factory class will evolve into a monstrosity where we will have to manage a very big and ugly dependency tree.
Dependency Injection frameworks, like Spring or Guice, are here to do this for us, and they do it well. Using frameworks like these, you don’t have to manage the dependency tree. They encourage you to program by interface and not by implementation, and it is their responsibility to create the necessary objects and to inject them where you need while maintaining the application’s dependency tree. Let’s see how the payment service looks using the Guice framework.
Like I said before, these frameworks encourage you to program by interface and not by implementation, just like the strategy pattern. So let’s start with our PaymentService interface and then build the CashPaymentService implementation.
interface PaymentService {
public void pay(Product product);
}
public class CashPaymentService implements PaymentService {
@Override
public void pay(Product product) {
System.out.println(“Payed with cash“);
}
}
The Cart object is the same, except is just using the @Inject annotation before the constructor. The annotation is used by Guice to know where to inject the dependencies.
public class Cart {
private List < Product > productList = new ArrayList < > ();
private PaymentService paymentService;
@Inject
public Cart(PaymentService paymentService) {
this.paymentService = paymentService;
}
public addProductToCart(Product product) {
productList.add(product);
}
public void buy() {
productList.stream().forEach(paymentService::pay);
}
}
We now have the interface and the implementation, so let’s see how we could bind them together. Guice requires a module for configuring the bindings. A module is just a class that extends the AbstractModule class. This class is the central point of the Guice framework. Here we keep all the dependency configurations, all the bindings, and their scopes. We are not forbidden from using a single module class — we can as how many of them as we want for different purposes. But let’s start with a single one and see what it does for us.
public class PaymentModule extends AbstractModule {
@Override
protected void configure configure() {
bind(PaymentService.class).to(CashPaymentService.class);
bind(Cart.class);
}
}
In this module, we specify that the PaymentService interface is bound to the CashPaymentService implementation. What this means is that when Guice sees a class that has a dependency to the PaymentService class, it will inject an instance of the CashPaymentService implementation. Let’s see how we can use it.
public class Demo {
public static void main(String[] args) {
Injector injector = Guice.createInjector(new PaymentModule());
// Now, whenever we want an instance we tell the injector to create it for us.
Cart cart = injector.getInstance(Cart.class);
}
}
The injector will now create a Cart object, and it will inject the necessary dependencies — in this case, a new CashPaymentService.
The module classes in Guice are very powerful. With them, we can manage all the instances in a big application. We can create binding annotations and use different scopes, like @Singleton, @SessionScoped, or @RequestScoped. Here is some great documentation for Guice, where you can see all the things that Guice can do for us.
Summary
Using Dependency Injection, your classes are not tightly coupled to their dependencies. You specify what dependencies you need in your class and are concerned just for the class's own business logic. Asking for interfaces (and not just for concrete classes) gives you the liberty to choose between multiple implementations.
In a very large application, it could become impossible to manage all the dependencies, so why not let someone trustworthy to do this? I showed, after a little introduction, what Guice can do, and Guice is not the only one. You can also use Spring or Play. They look different, but all accomplish the same thing. They manage your dependencies and inject them.
Opinions expressed by DZone contributors are their own.
Comments