Spring

Best Practices for Injecting Dependencies in Spring Boot

jin@catsriding.com
Feb 14, 2024
Published byJin
Best Practices for Injecting Dependencies in Spring Boot

Effective Ways to Inject Dependencies in Spring Boot Applications

Dependency injection (DI) is a core concept in the Spring framework that reduces coupling between components and improves flexibility. Key injection methods—constructor, setter, and field injection—each come with distinct characteristics. Understanding their differences and knowing when to apply them is essential for building maintainable and robust applications.

1. Dependency Injection Strategies in Spring

Spring offers multiple ways to inject dependencies into application components, with the most common approaches being setter injection, field injection, and constructor injection. Each strategy has its own advantages and trade-offs, and selecting the right one often depends on the specific use case.

Before comparing these injection strategies in detail, it’s important to understand the key annotations that facilitate dependency injection in Spring. These annotations allow developers to define, configure, and manage beans in a flexible and declarative manner.

  • @Autowired: Core annotation that allows Spring to automatically resolve and inject the appropriate bean.
  • @Qualifier: Specifies which bean to inject when multiple beans of the same type exist.
  • @Primary: Marks a bean as the default choice when multiple candidates are available.
  • @Bean: Declares a bean definition within a method, typically inside a configuration class.
  • @Configuration: Indicates that the class contains bean definitions managed by the Spring container.
  • @Component: General-purpose stereotype used to mark a class as a Spring-managed component.
  • @Service: Specialized stereotype annotation indicating a service-layer component.
  • @Repository: Stereotype used to define data access layer components.
  • @Controller: Declares a controller component within the Spring MVC web layer.

By combining these annotations appropriately, developers can take full advantage of Spring’s DI capabilities to create maintainable, modular, and testable applications.

1-1. Setter Injection

Setter injection assigns dependencies via setter methods. This approach is useful when object state needs to be updated dynamically after instantiation. Unlike field injection, it makes dependencies explicitly visible in the class definition, which improves readability. It also allows for reassigning dependencies even outside of the DI container, offering more flexibility. However, it compromises immutability and opens the possibility of creating objects without properly initialized dependencies, which may reduce safety.

SetterInjectionController.java
@Slf4j
@RestController
public class SetterInjectionController {

    private SetterInjectionService service;

    @Autowired
    public void setSetterInjectionService(SetterInjectionService setterInjectionService) {
        this.service = setterInjectionService;
    }

    @RequestMapping("/api/setter-injection")
    public String api() {
        return service.process();
    }
}

1-2. Field Injection

Field injection injects dependencies directly into class fields using the @Autowired annotation. This method is concise and straightforward, as it eliminates the need for explicit setter methods. It also makes dependencies immediately visible within the class. However, because injection is performed via reflection, it can be harder to test. Additionally, field injection does not enforce required dependencies at compile time, which can lead to partially constructed objects.

FieldInjectionController
@Slf4j
@RestController
public class FieldInjectionController {

    @Autowired
    private FieldInjectionService service;

    @RequestMapping("/api/field-injection")
    public String api() {
        return service.process();
    }
}

1-3. Constructor Injection

Constructor injection passes dependencies through the class constructor. This method ensures that required dependencies are clearly defined and enforces object consistency by guaranteeing that the object is fully initialized upon creation. It also allows the use of final to maintain immutability and helps prevent circular dependencies. In Spring 4.3 and above, if a class has a single constructor, @Autowired can be omitted.

ConstructorInjectionController.java
@Slf4j
@RestController
public class ConstructorInjectionController {

    private final ConstructorInjectionService service;

    public ConstructorInjectionController(ConstructorInjectionService service) {
        this.service = service;
    }

    @RequestMapping("/api/constructor-injection")
    public String api() {
        return service.process();
    }
}

2. Dependency Injection in Action

Selecting a dependency injection method in Spring involves more than just ease of implementation. Factors such as maintainability, scalability, and robustness must be taken into account. The right choice can improve the structure of the application and simplify ongoing maintenance. It also enables easier testing and reduces coupling between components, resulting in greater flexibility and extensibility. Understanding and applying the appropriate injection strategy based on the specific requirements of a project is essential for building resilient and well-structured systems.

2-1. Best Practices for Dependency Injection

In production environments, constructor injection is widely recommended due to its emphasis on immutability, reliability, and testability. By defining dependencies explicitly through constructors, objects are guaranteed to be fully initialized at creation time, making the code more robust and easier to understand.

ConstructorInjectionController.java
@Slf4j
@RestController
public class ConstructorInjectionController {

    private final ConstructorInjectionService service;

    public ConstructorInjectionController(ConstructorInjectionService service) {
        this.service = service;
    }
}
  • Immutability: Dependencies injected through the constructor cannot be changed later, ensuring stable object state. Using the final keyword helps enforce this behavior.
  • Explicit dependency declaration: Constructor injection requires declaring all dependencies upfront, preventing objects from being created in an incomplete state.
  • Circular dependency prevention: Spring can detect circular dependencies more effectively during constructor injection, making it easier to maintain a clean dependency structure.
  • Object consistency: All required dependencies are set during object construction, reducing the risk of runtime errors caused by missing injections.
  • Improved readability and maintainability: Clearly declaring dependencies in the constructor enhances code clarity and helps other developers understand component interactions.
  • Reduced reliance on the DI container: By not using @Autowired, components become easier to instantiate in test or non-Spring environments.

For simpler scenarios, Lombok’s @RequiredArgsConstructor can help reduce boilerplate by automatically generating a constructor for all final fields:

ConstructorInjectionController.java
  @Slf4j
  @RestController
+ @RequiredArgsConstructor
  public class ConstructorInjectionController {

      private final ConstructorInjectionService service;

-     public ConstructorInjectionController(ConstructorInjectionService service) {
-         this.service = service;
-     }
  }

However, in cases requiring selective injection—such as when using @Qualifier to specify a particular bean—declaring the constructor manually is unavoidable. Lombok cannot apply qualifiers or resolve ambiguity in such scenarios:

QualifierInjectionController.java
@Slf4j
@RestController
public class QualifierInjectionController {

    private final ApiService apiService;

    public QualifierInjectionController(
        @Qualifier("adminApiService") ApiService apiService
    ) {
        this.apiService = apiService;
    }
}

Manually defining the constructor in these situations ensures precise control over dependency resolution and promotes flexible, maintainable code.

2-2. Dynamic Dependency Injection

When the appropriate dependency must be determined at runtime—based on factors like user roles or configuration flags—dynamic injection becomes necessary. For example, different services might be needed depending on a user’s access level or specific runtime conditions. In such scenarios, selecting the appropriate bean dynamically ensures that the application remains flexible and modular. Spring provides several mechanisms to support this, including injection via List<T>, Map<K, V>, and the factory pattern.

To enable dynamic injection, begin by defining a common interface for your services. Then implement this interface across multiple service classes according to your application’s roles or logic:

ApiService.java
public interface ApiService {...}

Admin-specific implementation:

AdminApiService.java
@Slf4j
@Service
public class AdminApiService implements ApiService {...}

User-specific implementation:

UserApiService.java
@Slf4j
@Service
public class UserApiService implements ApiService {...}

These implementations serve as the foundation for various injection techniques described below.

2-2-1. List-Based Dependency Injection

Spring can inject all beans of a specific type as a List<T>. This allows filtering or selection based on runtime criteria:

DynamicListBasedInjectionController.java
@Slf4j
@RestController
public class DynamicListBasedInjectionController {

    private final List<ApiService> services;

    public DynamicListBasedInjectionController(List<ApiService> services) {
        this.services = services;
    }

    @GetMapping("/api/dynamic-list-based-injection")
    public String getApiResponse(@RequestParam String role) {
        ApiService service = getServiceByUserRole(role);
        log.info("api: role={}, service={}", role, service.getClass().getSimpleName());
        return service.process();
    }

    private ApiService getServiceByUserRole(String role) {
        return switch (role.toLowerCase()) {
            case "admin" -> services.stream()
                    .filter(service -> service instanceof AdminApiService)
                    .findFirst()
                    .orElseThrow(() -> new IllegalArgumentException("Admin service not found"));
            case "user" -> services.stream()
                    .filter(service -> service instanceof UserApiService)
                    .findFirst()
                    .orElseThrow(() -> new IllegalArgumentException("User service not found"));
            default -> throw new IllegalArgumentException("Invalid role: " + role);
        };
    }
}
2-2-2. Map-Based Dependency Injection

Alternatively, inject beans as a Map<K, V>, where the keys correspond to bean names. This provides a more concise and direct way to select services dynamically:

DynamicMapBasedInjectionController.java
@Slf4j
@RestController
public class DynamicMapBasedInjectionController {

    private final Map<String, ApiService> services;

    public DynamicMapBasedInjectionController(Map<String, ApiService> services) {
        this.services = services;
    }

    @GetMapping("/api/dynamic-map-based-injection")
    public String api(@RequestParam String role) {
        ApiService service = getServiceByUserRole(role);
        log.info("api: role={}, service={}", role, service.getClass().getSimpleName());
        return service.process();
    }

    private ApiService getServiceByUserRole(String role) {
        return switch (role.toLowerCase()) {
            case "admin" -> services.get("adminApiService");
            case "user" -> services.get("userApiService");
            default -> throw new IllegalArgumentException("Invalid role: " + role);
        };
    }

}
2-2-3. Factory-Based Dependency Injection

A factory encapsulates the logic for selecting the appropriate service, offering better separation of concerns and reusability:

ApiServiceFactory.java
@Slf4j
@Component
public class ApiServiceFactory {

    private final AdminApiService adminApiService;
    private final UserApiService userApiService;

    public ApiServiceFactory(AdminApiService adminApiService, UserApiService userApiService) {
        this.adminApiService = adminApiService;
        this.userApiService = userApiService;
    }

    public ApiService getServiceByRole(String role) {
        return switch (role.toLowerCase()) {
            case "admin" -> adminApiService;
            case "user" -> userApiService;
            default -> throw new IllegalArgumentException("Invalid role: " + role);
        };
    }
}

The controller then delegates role-based selection to the factory:

ApiServiceFactoryController.java
@Slf4j
@RestController
public class ApiServiceFactoryController {

    private final ApiServiceFactory apiServiceFactory;

    public ApiServiceFactoryController(ApiServiceFactory apiServiceFactory) {
        this.apiServiceFactory = apiServiceFactory;
    }

    @GetMapping("/api/dynamic-service-factory-injection")
    public String getApiResponse(@RequestParam String role) {
        ApiService service = apiServiceFactory.getServiceByRole(role);
        log.info("api: roles={}, service={}", role, service.getClass().getSimpleName());
        return service.process();
    }
}

Each method allows for dynamic resolution of dependencies while maintaining strong separation of concerns and clean code. The right strategy depends on the complexity of the selection logic and the structure of the application.

3. Wrapping It Up

We’ve examined the different strategies for dependency injection in Spring Framework, focusing on their strengths, limitations, and practical use cases. Among these, constructor injection stands out as the most robust and maintainable option for production code, promoting immutability, testability, and clear dependency management.

While setter injection provides flexibility for optional components and field injection can be convenient in testing contexts, both approaches come with trade-offs and are best reserved for specific, limited scenarios.

Selecting the right injection strategy is more than a matter of style—it’s a design choice that directly influences the structure, readability, and resilience of your application. When in doubt, constructor injection is the safest default.