Dependency Injection: The Basic6 min read
To start the article, let’s first look at the code below:
public class MonitoringService {
private final NotificationSender notificationSender;
public MonitoringService() {
this.notificationSender = new NotificationSender();
}
public void healthCheck(String sid) {
Service service = services.get(sid);
if (hasProblem(service)) {
notificationSender.send(notification);
}
}
}
class NotificationSender {
public void send(Notification notification) {}
}
record Notification(URI uri, String content) { }
As we can see, class MonitoringService
depends on class NotificationSender
to function, and we’ve initialized class NotificationSender
directly inside the constructor of class MonitoringService
. This makes our code fragile because now MonitoringService
has more than one responsibility, it doesn’t only care about how to use the notification sender, but also needs to take care of the construction of that class.
However, there is a design pattern in software engineering that comes in handy in this situation, which is dependency injection. Loosely speaking, dependency injection means an object or a function receives another object or function from the outside. In our above example, this basically indicates that instead of creating a new NotificationSender
object right inside the MonitoringService
constructor, we could parameterize the MonitoringService
constructor and pass its dependency in. Something like that:
public MonitoringService(NotificationSender notificationSender) {
this.notificationSender = notificationSender
}
By doing so, the MonitoringService
doesn’t have to care how to initialize the object, and it basically just uses what has been supplied to it. From now, we should formalize some vocabulary when talking about different roles in dependency injection:
- Client: the class that uses the service/dependency, in our example, the client is the
MonitoringService
class. - Service: a class that contains useful functionality that the client will use, in our case, the service is the
NotificationSender
class. - Interface: the client should not have to care about how the service is implemented, dependencies should be passed as interfaces instead of concrete classes. In our example, we’ve violated this rule.
- Injector: the class that introduces services to the client, meaning the place where we create the service object and pass it to the client.
Back to the code fragment at the beginning, let’s imagine our monitoring service gets more versatile and we want to send notifications to different channels, i.e. Mail and Slack. We now realize that we run into some problems since we cannot anything except change the client class:
- The service is directly initialized inside the client.
- The service is passed in as a concrete class.
To fix this problem, we now make the NotificationSender
as an interface, and then introducing its implementations:
interface NotificationSender {
void send(Notification notification);
}
class SlackNotificationSender implements NotificationSender {
@Override
public void send(Notification notification) {
// sending notification to slack
}
}
class MailNotificationSender implements NotificationSender {
@Override
public void send(Notification notification) {
// sending notification to email
}
}
public class MonitoringService {
private final NotificationSender notificationSender;
public MonitoringService(NotificationSender notificationSender) {
this.notificationSender = notificationSender
}
public void healthCheck(String sid) {
Service service = services.get(sid);
if (hasProblem(service)) {
notificationSender.send(notification);
}
}
...
}
The client MonitoringService
now only cares about its job, which is detecting the abnormality of the service, and if there is any, using the NotificationSender
service to send the notification channel, and the injector (as below) will determine which notification destination to use:
public static void main(String[] args) {
if (inDev) {
NotificationSender sender = new SlackNotificationSender();
MonitoringService monitoringService = new MonitoringService(sender);
monitoringService.healthCheck(service);
} else {
NotificationSender sender = new MailNotificationSender();
MonitoringService monitoringService = new MonitoringService(sender);
monitoringService.healthCheck(service);
}
}
To sum up, dependency injection offers several benefits:
- The separation between object creation and its usage.
- Services used by the client can support multiple implementations/configurations.
- Detailed implementation of the dependencies is completely agnostic to the client. The behavior of the client can be changed without changing its code.
The constructor injection is the most popular one, but there are other types of dependency injection as well, such as setter injection, in which we pass the dependency to the client by the setter method:
public void setNotificationSender(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
Dependency injection and inversion of control
Dependency injection is a form of inversion of control (IoC), this general term simply means a class delegates additional responsibilities such as object creation or control flow of the application to other classes/containers, and only cares about its main responsibility.
A good example of IoC is the Spring framework, which has a container that will control the creation as well as the life cycle of dependencies in our application known as beans. To create a bean in Spring, we annotate a class with @Configuration
and the methods of creating objects with @Bean
:
@Configuration
public class BeanProvider {
@Bean
public SlackNotificationSender getSlackNotificationSender() {
return new SlackNotificationSender();
}
}
Once the application starts, the notification sender object will be created and managed by the Spring container. And when we need it, we don’t have to create injectors as in the previous example, we basically just inject this bean into our client through the constructor, when the NotificationSender
object gets created we don’t need to know:
@Service
public class MonitoringService {
private final NotificationSender notificationSender;
public MonitoringService(@Autowired NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
}
Dependency injection and unit-testing
Back to our topmost example, if we hardly create the dependency right inside the class that we want to test, this becomes a handicap since we can only test it with only one dependency configuration, i.e. the NotificationSender
class. If we want a different implementation for NotificationSender
and test it, this means we need to change the MonitoringService
class, and these 2 classes are said to be tightly coupled. Let’s imagine now the NotificationSender
has some state variables and methods exposing these states:
public class NotificationSender {
private int totalSentNotifications;
private boolean hasReachedLimitPerDay;
public int getTotalSentNotifications() {
return this.totalSentNotifications;
}
public boolean isHasReachedLimitPerDay() {
return this.hasReachedLimitPerDay;
}
...
}
Each MonitoringService
instance in this case is associated with only one NotificationSender
state, now imagine we want to test the MonitoringService
in which its dependency NotificationSender
holds 5 totalSentNotifications
and is not reached the limit per day, we simply cannot do anything except modify the state of the NotificationSender
directly inside the MonitoringService
class to test it:
@Test
void testHealthCheckNotOkNotReachNotiLimit() {
monitoringService.setSuccess("service", false);
monitoringService.setTotalNotiSent(5);
monitoringService.setReachedLimit(false);
boolean ok = monitoringService.healthCheck("service");
assertFalse(ok)
}
However, if we pass the NotificationSender
as a dependency from the outside, the code is now much easier to test, and we can simulate different states of the NotificationSender
right inside the implementation itself, without making clients unnecessarily maintain states of other objects:
@Test
void testHealthNotOkNotReachedLimit() {
NotificationSender notificationSender = new SlackNotificationSender();
notificationSender = notificationSender.withTotalNotifications(5)
.reachedLimit(false);
monitoringService.setNotificationSender(notificationSender);
monitoringService.setSuccess("service", false);
boolean ok = monitoringService.healthCheck("service");
assertFalse(ok);
}
@Test
void testHealthOkReachedLimit() {
NotificationSender notificationSender = new SlackNotificationSender();
notificationSender = notificationSender
.withTotalNotifications(10)
.reachedLimit(true);
monitoringService.setNotificationSender(notificationSender);
monitoringService.setSuccess("service", true);
boolean ok = monitoringService.healthCheck("service");
assertTrue(ok);
}
Unit testing also becomes a burden if instead of testing the output of a single method or class itself, the class we want to test introduces some hidden dependencies, such as the database connection:
public class LogSaver {
private PersistenceUnit persistenceUnit;
public LogSaver() {
this.persistenceUnit = new DatabasePersistenceUnit(); // connect to the database
}
public boolean hasIncident(Service service) {
if (receiveNoResponse(service)) {
// some procedure...
persistenceUnit.save(incident);
return true;
}
...
}
}
Now we cannot test this class without the database connectivity, but this isn’t much obliged to our hasIncident
method to function, so we create the interface PersistenceUnit
and pass it to the LogSaver
class, later we can mock dependencies of the LogSaver
class or providing another way of persisting logs such as a file in our test:
@Test
public void testHasIncident() {
LogSaver logSaver = new LogSaver(new FilePersistenceUnit());
boolean hasIncident = logSaver.hasIncident("service");
assertTrue(hasIncident);
}
@Test
public void testHasIncidentWithMock() {
LogSaver logSaver = new LogSaver(Mockito.mock(PersistenceUnit.class));
boolean hasIncident = logSaver.hasIncident("service");
assertTrue(hasIncident);
}
In summary, dependency injection separates the construction of an object from its usage; it also makes it easier to write unit tests and change configuration options.