[JAVA] Dependency Injection to understand step by step

Introduction

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.

Subject of explanation

As a subject of explanation, consider the use case of task update in a simple task management system.

  1. The user opens the project issue list page
  2. The system returns a list of issues associated with the project
  3. The user selects an issue and updates the attributes (such as description)
  4. The system records the updated issues by overwriting.
  5. The system records the update history of the issue in a write-once manner.
  6. The system repeats 7 for all parties associated with the issue.
  7. The system sends notifications using the configured method (either email or Slack).

The class structure to achieve this use case would look like this, based on a typical three-layer layered architecture.

classes.png

6 levels to reach DI

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

Level 1: static method

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.

  1. Interface not available --The Dependency Inversion Principle (https://ja.wikipedia.org/wiki/%E4%BE%9D%E5%AD%98%E6%80%A7%E9%80%86%E8%BB%A2%E3%81%AE%E5%8E%9F%E5%89%87) intended in the class diagram is completely ignored.
  2. Polymorphism is not available --For example, if notification methods other than email and Slack increase, users will have to add branches.
  3. Unit testing is difficult --Originally, if you replace the dependency with mock with a library like Mockito, you can easily support unit testing of components, but there are technical difficulties in replacing static methods (although not impossible). , Should not be done in most cases due to its large side effects)
  4. Heavy use of static methods and static fields has a negative impact on the overall design ――Since such a design is a very rudimentary error in Java, detailed explanation is omitted (see Search results for "static uncle" if further explanation is needed).

Level 2: Create your own dependent instance

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.

  1. Dependent implementation is not hidden --The interface of Repository and Gateway should be cut out to hide the lower layer, but the dependency source is aware of the implementation class.
  2. The source must manually manage the life cycle of the dependency --It is inefficient to instantiate Service, Repository, Gateway, etc. that do not have a state each time.
  3. Similar initialization code is duplicated in multiple places when parameters are required to generate the dependency --Although the constructor parameters are omitted in the above example, components such as Repository and Gateway should have environment-dependent settings related to communication.

Level 3: Build your own object graph

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.

  1. Dependent implementation is only partially hidden --At the top layer, you need to be aware of the implementation of the dependency
  2. Similar object graph construction process must be implemented in duplicate --Since the lower layer components are shared by multiple upper layer components in slightly different ways, duplicate implementation is forced to build similar and non-similar object graphs in various places.
  3. When using the framework at the top layer, you cannot freely control the generation of components. --For example, in a typical web application, creating a Controller is the responsibility of the framework, not the developer's code.

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.

Level 4: Own Factory

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.

  1. Factory implementation becomes complicated --As your application grows in size, you'll have to spend more time manually writing Factory code.
  2. Manual management of life cycle remains --Each Factory implements Singleton pattern by itself.

Level 5: Service Locator

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.

  1. The standard code for searching for dependencies becomes a little complicated. --The difference is obvious when compared with the code using DI described later.
  2. Unit tests get a little complicated --If you want to replace the dependency with mock in the unit test, you need to register with Service Locator.
  3. Impaired component portability --Because all dependency resolution depends on Service Locator, components cannot be shared with other projects that do not use Service Locator.
  4. There is no practical framework --As of 2020, there is no strong Java framework dedicated to Service Locator (actually, the core classes of DI framework can also be used as Service Locator, but there is little benefit to justify such usage).

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.

Level 6: Dependency Injection

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.

Benefits realized by DI so far

So far, let's briefly summarize the benefits realized by DI.

  1. Automatic construction of object graph
  2. Complete concealment of lower layer implementations
  3. Ease of unit testing of components
  4. Component portability
  5. Component lifecycle management

Further benefits of 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).

Managing environment-dependent settings

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.

Declarative lifecycle management

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.

Pre-processing and post-processing insertion (AOP)

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.

Issues in using DI

Let's also summarize the points that tend to be problematic when using DI for actual development.

Framework selection

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

Injection method

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.

  1. Inject from the constructor
  2. Inject from a method such as Setter
  3. Inject directly into the field

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.

Component setting method

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

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.

Languages ​​other than Java and DI

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.

in conclusion

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

Dependency Injection to understand step by step
About "Dependency Injection" and "Inheritance" that are easy to understand when remembered together
How to set Dependency Injection (DI) for Spring Boot
Dagger2 --Android Dependency Injection
What is DI (Dependency Injection)
DI: What is Dependency Injection?
Summarize Ruby and Dependency Injection
Java thread to understand loosely