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.

Previous Article
Next Article
Every support is much appreciated ❤️

Buy Me a Coffee