Dependency Injection (hereinafter abbreviated as "DI") is a mechanism that solves various problems related to component dependencies in a nice way. "Component dependency" refers to a relationship that connects from the upper layer to the lower layer, for example, Controller → Service → Repository in a general layered architecture. "Solving nicely" means that the framework does a good job without the need for developers to do it manually.
DI is almost an essential mechanism in modern Java application development. I am mainly involved in application development and operation with the backend as the main battlefield, but if you develop applications in Java, whether it is a Web application or a batch launched from the command line, except for disposables In most cases you are leveraging DI. More than 15 years after the emergence of the DI concept (https://martinfowler.com/articles/injection.html), the framework is mature enough. In addition, it is rare that a fatal problem is discovered in code reviews when using DI (as opposed to exception handling that still causes problems frequently).
However, there is a mysterious confusion in the discourse about DI in the world. For example, there are some articles at the top of the search that cover only the first steps of DI and barely explain the practical benefits. In addition, on social media, etc., it is observed that discussions about the necessity of DI in Java (as the author says) are repeated.
In this article, we will step by step on what technical challenges the need for DI arises so that we can correct any confusion over DI discourse. It also describes important features that can be used after reaching DI step by step, as well as issues to be aware of when using DI.
As a subject of explanation, consider the use case of task update in a simple task management system.
The class structure to achieve this use case would look like this, based on a typical three-layer layered architecture.
In this chapter, we will explain the process of solving problems to reach DI in 6 levels.
--Level 1: static method --Level 2: Create your own dependent instance --Level 3: Build your own object graph --Level 4: Your own Factory --Level 5: Service Locator --Level 6: Dependency Injection
First, as the simplest method, let's implement the processing of Service / Repository / Gateway with static method. The implementation of Service processing corresponding to step 4-7 of the use case is shown below.
public class IssueService {
public static void update(Issue issue) {
//Record the issue by overwriting
IssueRepository.update(issue);
//Record the update history of the issue by adding
Activity activity = new Activity();
activity.setIssue(issue);
String description = "Issue updated: " + issue.getUpdateSummary();
activity.setDescription(description);
ActivityRepository.update(activity);
//Send notifications using the set method for all parties associated with the issue
issue.getWatchers().forEach(user -> {
switch (user.getNotificationType()) {
case Email:
EmailNotificationGateway.send(user, activity);
break;
case Slack:
SlackNotificationGateway.send(user, activity);
break;
default:
throw new IllegalStateException("This could not happen");
}
});
}
}
There are a number of problems with this method. Do not imitate in actual work.
Next, let's implement it by a method in which the dependency source directly generates the dependency destination.
public class IssueService {
private final IssueRepository issueRepository = new MyBatisIssueRepository();
private final ActivityRepository activityRepository = new MyBatisActivityRepository();
private final Map<NotificationType, NotificationGateway> notificationGateways = Map
.of(NotificationType.Slack, new SlackNotificationGateway(),
NotificationType.Email, new EmailNotificationGateway());
public void update(Issue issue) {
//Record the issue by overwriting
issueRepository.update(issue);
//Record the update history of the issue by adding
Activity activity = new Activity();
activity.setIssue(issue);
String description = "Issue updated: " + issue.getUpdateSummary();
activity.setDescription(description);
activityRepository.update(activity);
//Send notifications using the set method for all parties associated with the issue
issue.getWatchers().forEach(user -> notificationGateways
.get(user.getNotificationType()).send(user, activity));
}
}
This solves the Level 1 problem, but the following problems remain.
If you're having trouble generating dependencies directly, try pushing those responsibilities out of the class. IssueService decides to receive all dependencies in the constructor.
public class IssueService {
private final IssueRepository issueRepository;
private final ActivityRepository activityRepository;
private final Map<NotificationType, NotificationGateway> notificationGateways;
public IssueService(IssueRepository issueRepository,
ActivityRepository activityRepository,
Map<NotificationType, NotificationGateway> notificationGateways) {
this.issueRepository = issueRepository;
this.activityRepository = activityRepository;
this.notificationGateways = notificationGateways;
}
public void update(Issue issue) {
//Record the issue by overwriting
issueRepository.update(issue);
//Record the update history of the issue by adding
Activity activity = new Activity();
activity.setIssue(issue);
String description = "Issue updated: " + issue.getUpdateSummary();
activity.setDescription(description);
activityRepository.update(activity);
//Send notifications using the set method for all parties associated with the issue
issue.getWatchers().forEach(user -> notificationGateways
.get(user.getNotificationType()).send(user, activity));
}
}
The responsibility for building dependencies is assigned to the top-level IssueController.
public class IssueController {
private final IssueService issueService = new IssueService(
new MyBatisIssueRepository(), new MyBatisActivityRepository(),
Map.of(NotificationType.Slack, new SlackNotificationGateway(),
NotificationType.Email, new EmailNotificationGateway()));
public void update(Issue issue) {
issueService.update(issue);
}
}
This method also has many problems.
As I mentioned briefly at the beginning, there are some articles that end the explanation of DI with explanations up to level 3, but I do not agree with such a method. There are three reasons. First, because his Assembly element in Original is unfairly neglected, it is one-sided as a pattern ancestor. Secondly, as I mentioned the problem in this section, his automated construction of his own object graph without assembly is not practical as a method. Finally, unless the "further advantages" described later in this article are taken into consideration, the necessity of use cannot be correctly determined.
If it's a problem to personalize the generation of dependencies, try creating a dedicated component to take on that responsibility. First, it is a class responsible for generating Repository.
public class RepositoryFactory {
private static final RepositoryFactory INSTANCE = new RepositoryFactory();
private final Map<Class<?>, Object> repositories = Map.of(
IssueRepository.class, new MyBatisIssueRepository(),
ActivityRepository.class, new MyBatisActivityRepository());
private RepositoryFactory() {
}
public static RepositoryFactory getInstance() {
return INSTANCE;
}
@SuppressWarnings("unchecked")
public <T> T get(Class<T> clazz) {
return (T) repositories.get(clazz);
}
}
Also, create a class that is responsible for generating the Gateway. This has a requirement to replace the implementation class at runtime, so the logic is a little different from the repository generation.
public class NotificationGatewayFactory {
private static final NotificationGatewayFactory INSTANCE = new NotificationGatewayFactory();
private final Map<NotificationType, NotificationGateway> notificationGateways = Map
.of(NotificationType.Slack, new SlackNotificationGateway(),
NotificationType.Email, new EmailNotificationGateway());
private NotificationGatewayFactory() {
}
public static NotificationGatewayFactory getInstance() {
return INSTANCE;
}
public NotificationGateway get(NotificationType type) {
return notificationGateways.get(type);
}
}
By using these, the processing of IssueService can be rewritten as follows.
public class IssueService {
private final IssueRepository issueRepository = RepositoryFactory
.getInstance().get(IssueRepository.class);
private final ActivityRepository activityRepository = RepositoryFactory
.getInstance().get(ActivityRepository.class);
private final NotificationGatewayFactory notificationGatewayFactory = NotificationGatewayFactory
.getInstance();
public void update(Issue issue) {
//Record the issue by overwriting
issueRepository.update(issue);
//Record the update history of the issue by adding
Activity activity = new Activity();
activity.setIssue(issue);
String description = "Issue updated: " + issue.getUpdateSummary();
activity.setDescription(description);
activityRepository.update(activity);
//Send notifications using the set method for all parties associated with the issue
issue.getWatchers().forEach(user -> notificationGatewayFactory
.get(user.getNotificationType()).send(user, activity));
}
}
This solves the Level 2-3 problem, but the following problems remain.
If there are limits to what you can do on your own, introduce a mechanism that will automatically resolve them. It is a Service Locator pattern that is also contrasted with DI in Original.
public class IssueService {
private final IssueRepository issueRepository = ServiceLocator.getInstance()
.get(IssueRepository.class);
private final ActivityRepository activityRepository = ServiceLocator
.getInstance().get(ActivityRepository.class);
@SuppressWarnings("serial")
private final Map<NotificationType, NotificationGateway> notificationGateways = ServiceLocator
.getInstance()
.get(new TypeToken<Map<NotificationType, NotificationGateway>>() {
});
public void update(Issue issue) {
//Record the issue by overwriting
issueRepository.update(issue);
//Record the update history of the issue by adding
Activity activity = new Activity();
activity.setIssue(issue);
String description = "Issue updated: " + issue.getUpdateSummary();
activity.setDescription(description);
activityRepository.update(activity);
//Send notifications using the set method for all parties associated with the issue
issue.getWatchers().forEach(user -> notificationGateways
.get(user.getNotificationType()).send(user, activity));
}
}
The ServiceLocator class that appears in the above code has various useful functions such as searching the implementation class from the interface, managing the life cycle of the component properly, inserting custom initialization code for a special component, and so on. It is a fictitious class that should be.
This solves the problems associated with self-implementation, but the following problems remain.
The Original argues that Service Locator should be used as a more convenient means than DI, but as of 2020, when the DI framework became sufficiently easy to use, its validity is no longer valid. I think it's small.
Explicit centralized management of dependencies, whether on its own or not, seems unreasonable. Now let's change our policy to use the implicit method.
This is where DI, the subject of this article, finally comes into play. The component code itself is similar to Level 3, except that it is annotated for DI (JSR-330 @Inject) (annotation for some DI frameworks). You may not even need it). When this component is used under DI framework management, the dependent component that is generated is automatically injected into the constructor.
public class IssueService {
private final IssueRepository issueRepository;
private final ActivityRepository activityRepository;
private final Map<NotificationType, NotificationGateway> notificationGateways;
@Inject
public IssueService(IssueRepository issueRepository,
ActivityRepository activityRepository,
Map<NotificationType, NotificationGateway> notificationGateways) {
this.issueRepository = issueRepository;
this.activityRepository = activityRepository;
this.notificationGateways = notificationGateways;
}
public void update(Issue issue) {
//Record the issue by overwriting
issueRepository.update(issue);
//Record the update history of the issue by adding
Activity activity = new Activity();
activity.setIssue(issue);
String description = "Issue updated: " + issue.getUpdateSummary();
activity.setDescription(description);
activityRepository.update(activity);
//Send notifications using the set method for all parties associated with the issue
issue.getWatchers().forEach(user -> notificationGateways
.get(user.getNotificationType()).send(user, activity));
}
}
The DI framework and Service Locator have the same functions as searching for implementation classes from the interface, managing the life cycle of components properly, and inserting custom initialization code for special components. ..
This solves the Level 5 problem. No need to explicitly search for dependencies, if you want to use mock in unit tests you can just plug it in from the constructor, the components are portable (ignoring annotations), and there are several options for a working framework. is there.
There are no major barriers to the introduction of the DI framework itself. If it is a modern web framework, there is a function to link with the DI framework, and all components under Controller can be managed by the DI framework. You can also include for example, such a simple initialization code in the main method in a command line application.
So far, let's briefly summarize the benefits realized by DI.
With the introduction of DI, the following advantages can also be enjoyed. In particular, managing environment-dependent settings is as important as the advantages mentioned above (note that these advantages can be technically achieved with Service Locator as well).
The DI framework can inject not only the dependent components, but also the values of so-called environment-dependent settings into the components. For example, the Gateway implementation originally has parameters for external communication such as endpoint URL, number of retries, and number of timeout seconds, and the values will differ depending on the environment such as development, testing, staging, and production. These values can also be injected into the component using the DI framework's environment-dependent configuration mechanism.
See the Spring Boot Externalized Configuration (https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config) and Guice Names.bindProperties (https://stackoverflow.com/questions/3071891/guice-and-properties-files) samples for specific code examples.
The DI framework provides a mechanism for declaratively managing the life cycle of components. Regarding the life cycle of a component, there may be various requirements such as wanting to create an instance on a request-by-request basis, having one common instance without a state is sufficient, and wanting to sustain an instance across multiple requests during a certain procedure. The DI framework makes it easy to meet these requirements.
See the Spring Scope (https://www.baeldung.com/spring-bean-scopes) and Guice Scope (https://github.com/google/guice/wiki/Scopes) samples for specific code examples.
AOP is simply a mechanism to insert common pre-processing and post-processing in a horizontal skewer for multiple functions that are not originally related (strict explanation is omitted). With regard to AOP, there are still some bad words such as "it can only be used for logging", partly because of the reaction that received excessive expectations in the past. However, in reality, it is used as a mechanism to support cache and transaction control under the surface in addition to logging. Also, it is possible that his AOP will come into play for Exception Handling.
For specific code examples, see the samples in Spring AOP and Guice AOP.
Let's also summarize the points that tend to be problematic when using DI for actual development.
There are several frameworks that support the Dependency Injection standard JSR-330 in Java. For example, JSR-330 project on GitHub has a list of frameworks that have passed the test, including minor ones. You can also find some other options by doing a Search by JSR-330 implementation.
However, in actual application development, the selection of DI framework itself is rarely a problem. In many cases, the options are decided without a body or a lid due to the following external factors.
-When utilizing the Spring ecosystem, [Spring Framework](https://spring.io/ projects/spring-framework) -When leveraging the Jakarta EE ecosystem, the CDI implementation bundled with the application server (such as Weld) --For simple applications that don't require an ecosystem like the one above, Guice --For Android applications, Guice or Dagger
There are three main ways the DI framework injects dependencies into your application: The sample code above is a method of injecting from his first constructor.
In general, the method of injecting from the constructor is recommended because the invariance of the object can be maintained. However, some sites may prefer to inject directly into the field because the code is the simplest.
Please refer to Guice Injection Method Explanation for a concrete code example.
In the early days of DI, the method of listing all components in an xml configuration file was the mainstream, but nowadays it is almost replaced by the method of using annotations.
In addition, there are extension points that can be written in code for dependencies that cannot be expressed by declarative settings in configuration files and annotations.
See Guice's @Provides and Multibindings samples for specific code examples of extension points.
Component scan is a process that analyzes the components in the classpath when the application starts and automatically acquires the metadata required for DI. This makes it possible to meet requirements such as associating an interface with an implementation class without explicitly specifying it in the settings, or finding a setting problem before starting. On the other hand, the wider the scan range, of course, the more obvious weaknesses it has in terms of boot performance.
The handling of component scans depends on the framework. Spring Framework and CDI emphasize the ease of component scanning. Guice, on the other hand, eliminates component scans to keep things simple and fast.
So far we have described the advantages of DI in Java application development, but these discussions cannot be unconditionally applied to languages other than Java. More flexible dynamically typed languages (such as Ruby) wouldn't be as constrained as Java by the Level 1 method in this article. Also, for more expressive statically typed languages (such as Scala), there may be an option to implement a DI-like mechanism more securely with language specification level support.
The above is a summary of the advantages and issues of DI in Java application development. We hope that it will be useful for DI utilization and productive DI discussions after understanding the background.
Recommended Posts