The Spring Framework: Ep. 1 - What is dependency injection?
Dependency Injection (DI) is a fundamental building block of the Spring framework. It is used extensively throughout the framework to manage the creation and lifecycle of objects and their dependencies.
Before diving into Spring it is mandatory to understand dependency injection. This is such a fundamental concept that you don't want to skip it. I know it might be boring but spend some time reading and thinking about it.
Instead of starting with a confusing definition, let's see what problem dependency injection is trying to solve. I will use a concrete example to make it easier to understand. At this point, we don't care about the Spring framework. We will use plain Java.
public class NotificationSender {
private SmsSender smsSender;
public NotificationSender() {
smsSender = new SmsSender();
}
public void send(String to, String message) {
smsSender.send(to, message);
//do other thing like saving the notification into the database
}
}
We aim to build a notification service that sends SMS notifications to our customers. Note that a better name for the class would be NotificationService, but I don't want you to make any assumptions just because it has "Service" in the name.
public class SmsSender {
public void send(String to, String message) {
System.out.println("Sending SMS...Message: " + message + " to: " + to);
}
}
Once this is done, other applications in our company can use the NotificationSender to send notifications. There is only one issue: some applications notify customers via email.
public class EmailSender {
public void send(String to, String message) {
System.out.println("Sending EMAIL...Message: " + message + " to: " + to);
}
}
Ok, so we added an email sender class, but the NotificationSender is tightly coupled with the SmsSender. Users of our service should have the flexibility to plug in the SmsSender or the EmailSender, depending on their needs. How do we achieve this?
A core principle of OOP says that we should program to an interface. This means that a high-level module, like our NotificationSender, should not depend on low-level modules like SmsSender or EmailSender. Instead, it should depend on abstractions. The channel through which we send messages it's an implementation detail, and we should abstract it.
Let's create a Sender interface that has only one method, send.
public interface Sender {
void send(String to, String message);
}
Both EmailSender and SmsSender will implement this interface.
public class EmailSender implements Sender {
@Override
public void send(String to, String message) {
System.out.println("Sending EMAIL...Message: " + message + " to: " + to);
}
}
public class SmsSender implements Sender {
@Override
public void send(String to, String message) {
System.out.println("Sending SMS...Message: " + message + " to: " + to);
}
}
public class NotificationSender {
private Sender sender;
public NotificationSender() {
sender = new SmsSender();
}
public void sendNotification(String to, String message) {
sender.send(to, message);
//do other thing like saving the notification into the database
}
}
Although NotificationSender now depends on an interface, sendNotification method still needs a concrete implementation. So as long as we instantiate the SmsSender, the EmailSender, or other senders in the constructor, we didn't achieve too much. Remember, we want our class to work without knowing all the possible sender implementations. Using an advanced technique, reflection, we can search for the class that implements the Sender interface and instantiate that class. But then, the NotificationSender would have too many responsibilities, which is against the SOLID principles. In a large enterprise application, we might need to do this a lot.
Another major drawback is that if the SmsSender or the EmailSender has any dependencies(which need to be passed via the constructor), our NotificationSender has to be aware of these so it can pass them down.
So here comes the dependency injection to save the situation. Our class can receive the Sender via the constructor or a setter. So we let someone else, an Assembler, search for the appropriate implementation of the Sender interface, instantiate it and pass it to our class.
public class NotificationSender {
private Sender sender;
public NotificationSender(Sender sender) {
this.sender = sender;
}
public void sendNotification(String to, String message) {
sender.send(to, message);
//do other thing like saving the notification into the database
}
}
This technique is called Dependency Injection(DI). It is a way to implement a more general principle, Inversion of Control(IoC). IoC states that a software module or system is not responsible for creating or controlling the objects it uses. Instead, the objects are passed to it by an external entity, such as a container or a framework.
In Spring Framework, Spring Container is the Assembler that will find and provide the appropriate Sender.
There are many benefits of dependency injection. Here are some of the most important:
Loose Coupling: Classes are not tightly bound to their dependencies;
Improved testability: Dependencies can be easily replaced with mock objects for testing, which makes it easier to test individual components in isolation.
Increased flexibility: Classes can be easily configured to use different implementations of their dependencies without changing the code.
In the end, Dependency Injection is nothing more than passing arguments to a method 🧠.
In the next episode, we will see how dependency injection works in Spring.