How to Use Dependency Injection in Java: Tutorial with Examples

What is Dependency Injection in Java?

Creating robust and maintainable software is constantly pursued in the ever-evolving landscape of Java development. One concept that stands out in achieving these goals is Dependency Injection (DI). This technique enhances code readability and promotes a more flexible and scalable architecture. At its core, Dependency Injection (DI) is a design pattern in Java that addresses the issue of managing dependencies between components in a software system. 

In a traditional setup, an object often creates or obtains its dependencies internally, resulting in tightly coupled code that can be challenging to maintain and test. Dependency Injection flips this paradigm by externalizing and injecting the dependencies into the object, hence the name “Dependency Injection.”

In Java, this often involves passing the required dependencies as parameters to a class’s constructor or through setters, allowing for a more modular and flexible code structure. Dependency Injection promotes code reusability, testability, and overall system scalability by decoupling components and externalizing dependencies.

Critical Points of Dependency Injection in Java

PointsDescription
Decoupling ComponentsDependency Injection reduces the tight coupling between components by ensuring that a class does not create its dependencies internally but receives them from an external source.
Modular and Maintainable CodeWith dependencies injected from the outside, each code component becomes more modular and easier to maintain, as changes in one code component do not necessarily affect others.
TestabilityDI facilitates unit testing by allowing for easy substituting dependencies with mock objects or alternative implementations. As a result, isolating and testing individual components is simpler.

What is Inversion of Control?

Understanding IoC is crucial in comprehending the philosophy behind Dependency Injection, as they are closely related concepts working in harmony to create more maintainable and scalable software architectures.

Inversion of Control (IoC) is a broader design principle that underlies Dependency Injection. It represents a shift in the flow of control in a software system. In a traditional procedural model, the main program or a framework controls the execution flow, deciding when to call specific functions or methods. In contrast, IoC flips this control by externalizing the flow of execution.

In the context of Dependency Injection, IoC means that a higher-level component or framework controls the flow and manages the dependencies of lower-level components. In other words, instead of a class controlling the instantiation of its dependencies, the control is inverted, and dependencies come from an external source.

Critical Points of Inversion of Control

PointsDescription
Externalized ControlIoC shifts the flow control from individual components to a higher-level entity, such as a framework or container.
Loose CouplingBy externalizing control, IoC promotes loose coupling between components, making the system more flexible and adaptable to changes.
Ease of ExtensionSystems following IoC are often more extensible, as new components can be added without modifying existing code, thanks to the externalized control of dependencies.

Classes of Dependency Injection

In Dependency Injection, several key classes play distinct roles in achieving the desired decoupling and flexibility. Understanding the responsibilities of these classes is fundamental to mastering Dependency Injection in Java. Let’s review these classes in detail:

Client Class

It is the consumer of services or functionalities other classes provide. Its primary purpose is to consume services without being concerned about how they are instantiated or configured. That’s why DI relies on externalized dependencies rather than creating them internally. Let’s see an example below.

public class ProductClient {
	private ProductService productService;

	// Constructor Injection
	public ProductClient(ProductService productService) {
    	     this.productService = productService;
	}

	public void displayProductDetails() {
    	     Product product = productService.getProductById(123);
    	    System.out.println("Product Details: " + product);
	}
}

Here, the ProductClient class relies on the ProductService to retrieve product details. The dependency (ProductService) is injected into the ProductClient through constructor injection, promoting loose coupling.

Injector Class

The injector class is responsible for injecting dependencies into client classes. It acts as a bridge between the client and the services it requires. In our example, an injector might look like this:

public class ProductInjector {
    public static void main(String[] args) {
        ProductService productService = new ProductServiceImpl();
        ProductClient productClient = new ProductClient(productService);
        productClient.displayProductDetails();
    }
}

In this example, the ProductInjector creates an instance of ProductService and injects it into the ProductClient. The code sample demonstrates the externalized control of dependencies, a fundamental aspect of Dependency Injection.

Service Class

Service classes encapsulate the business logic and provide functionalities to the client. Let’s create a simple implementation for the ProductService:

public interface ProductService {
	Product getProductById(int productId);
}

public class ProductServiceImpl implements ProductService {
	private ProductRepository productRepository;

	// Constructor Injection for Repository
	public ProductServiceImpl() {
    	this.productRepository = new ProductRepositoryImpl();
	}

	@Override
	public Product getProductById(int productId) {
    	return productRepository.findById(productId);
	}
}

In this example, the ProductServiceImpl depends on a ProductRepository for data access. The ProductRepository could be another interface representing data access operations.

To illustrate the interaction with a database, let’s extend our example with a ProductRepository that interacts with a database:

public interface ProductRepository {
    Product findById(int productId);
}

public class ProductRepositoryImpl implements ProductRepository {
    // Simulating database interaction
    @Override
    public Product findById(int productId) {
        // Database query logic here
        // For simplicity, let's return a dummy Product
        return new Product(productId, "Sample Product", 49.99);
    }
}

Please note that this is a simplified example, but in a real-world scenario, the ProductRepositoryImpl class would contain database-specific logic for querying and retrieving product information.

These examples illustrate how DI allows for the externalized control of dependencies, leading to more modular and maintainable code. 

The client class (ProductClient) relies on a service (ProductService), which in turn depends on a repository (ProductRepository). 

This hierarchical structure enables the easy substitution of components and facilitates unit testing.

Types of Dependency Injection

While we have seen how we can achieve DI, it may present challenges, such as initial setup complexities and the need for careful design to maximize its advantages. In this section, we will discuss the types of dependency injection by emphasizing the choice between Constructor Injection, Field or Property-Based Injection, and Setter Injection. 

These types of injection can help developers achieve modularity and loose coupling. Furthermore, these types depend on specific use cases and project requirements, and knowing this helps the developer make informed decisions when implementing dependency injection in their Java projects. Let’s explore them in more detail:

Constructor Injection

Constructor injection involves passing dependencies as parameters to a class’s constructor. This method ensures that the required dependencies are provided during object creation. Consider the following example:

public class ProductServiceClient {
    private final ProductService productService;

    // Constructor Injection
    public ProductServiceClient(ProductService productService) {
        this.productService = productService;
    }

    // Client method using the injected service
    public void displayProductDetails() {
        Product product = productService.getProductById(123);
        System.out.println("Product Details: " + product);
    }
}

Constructor injection promotes a clear and explicit declaration of dependencies. It ensures that an object cannot be instantiated without the required dependencies, leading to better maintainability and reducing the risk of null dependencies. This type of injection is beneficial when dependencies are essential for the proper functioning of the object.

Field or Property-Based Injection

Field or property-based injection assigns dependencies directly to class fields or properties, which can be achieved through annotations or configuration files. Here’s a simplified example using the @Autowired annotation in Spring.

public class ProductServiceClient {
	@Autowired
	private ProductService productService;

	// Client method using the injected service
	public void displayProductDetails() {
    	Product product = productService.getProductById(123);
    	System.out.println("Product Details: " + product);
	}
}

The field-based injection is convenient when frameworks like Spring support automatic dependency injection through annotations. It suits scenarios where dependencies remain constant throughout the object’s lifecycle. However, caution is needed to avoid tight coupling, and considerations such as encapsulation and immutability should be considered.

Setter Injection

Setter injection involves providing setter methods in a class for each dependency, allowing external entities to set those dependencies. Here’s an example:

public class ProductServiceClient {
    private ProductService productService;

    // Setter Injection
    public void setProductService(ProductService productService) {
        this.productService = productService;
    }

    // Client method using the injected service
    public void displayProductDetails() {
        Product product = productService.getProductById(123);
        System.out.println("Product Details: " + product);
    }
}

Setter injection provides flexibility, allowing dependencies to be changed or updated after the object is created. It is suitable when a class can function with optional dependencies or when dependencies may change during the object’s lifecycle. Setter injection is particularly beneficial for scenarios where the object may be reused with different configurations.

How Does Dependency Injection Work?

To implement Dependency Injection, a few prerequisites need to be in place. Firstly, a clear understanding of the dependencies within the system is essential. This includes identifying the services and components that require externalized dependencies. 

A Dependency Injection framework or container, such as Spring Framework in Java, is often utilized to automate the injection process.

Tools Needed for a DI Setup

  • Dependency Injection Framework: Choose a suitable DI framework or container for Java, such as Spring Framework or Google Guice.
  • Configuration: The dependencies and injection methods through XML configuration files, annotations, or Java-based configurations.
  • Dependency Provider: Ensure the existence of classes or components that will provide the required dependencies to the dependent classes.

The Advantages of Dependency Injection

Dependency Injection brings about a paradigm shift in Java development, introducing several advantages that contribute to creating robust and maintainable software. Let’s look at some of the benefits it offers:

  • Improved Testability: DI facilitates easier unit testing by substituting actual dependencies with mock objects. This is crucial for writing comprehensive and reliable tests.
  • Enhanced Maintainability: Loose coupling achieved through DI simplifies modifying or extending the codebase. Changes in one component have minimal impact on others, making the codebase more maintainable.
  • Scalability: DI promotes a modular structure, making scaling and expanding the system easier. New components can be added with minimal impact on existing code.
  • Reduction of Code Duplication: In essence, DI addresses the challenge of code duplication by integrating/streamlining the management of dependencies. With the proper strategy in place of control of dependencies, Java projects can achieve code reduction.

Software Security Perspective  

Keep in mind that when using 3rd party libraries, especially open source and frameworks, special attention should be given to their maintenance and dependency hygiene. It’s recommended to keep updating the libraries as frequently as possible, to the latest stable and risk clean version. This will help you achieve better code maintainability and reduce software security risks if and when a new vulnerability is detected. A good and easy way of doing that is by using the free Renovate tool which helps automate the dependency upgrade process with minimal risks if breaking something on the way and high visibility.  

Summary

In this article, we have explored Dependency Injection’s strength as a design pattern, empowering developers to craft code that is not only clean, modular, and maintainable but also the profound impact it can have on the architecture of a Java project. 

We have also shed light on its intrinsic relationship with Inversion of Control (IoC). While IoC is a broader design principle, encapsulating the external control of execution flow, DI emerges as the specific technique for endowing components with dependencies externally.

Learn more about updating your dependencies

Itay Badichi / About Author

Development Team Leader at Mend.io