Dependency Injection in Spring Boot

Dependency Injection in Spring Boot

Dependency Injection (DI) isn’t just a buzzword in Spring Boot—it’s the architectural glue that enables modular, testable and scalable code. While most developers understand the basics, senior engineers need to grasp advanced scenarios like circular dependencies, bean lifecycle management and performance optimizations. This article cuts through the noise, focusing on practical mastery of DI’s nuances in real-world Spring Boot applications.

Why Dependency Injection Matters in Spring Boot

Spring Boot’s DI framework shifts responsibility for object creation and wiring from developers to the IoC (Inversion of Control) container. This means:

  • Loose Coupling: Components depend on abstractions, not concrete implementations.
  • Testability: Dependencies can be mocked or stubbed effortlessly.
  • Maintainability: Changes propagate cleanly across the codebase.

But to leverage these benefits at scale, you need to go beyond @Autowired. Let’s dive deeper.

The Three Faces of Dependency Injection: Constructor, Setter and Field

1. What Is Constructor Injection?

Constructor Injection is a DI technique where dependencies are provided via a class’s constructor. The Spring IoC container automatically resolves and injects these dependencies when creating the bean.

Why It’s the Gold Standard

  1. Immutability: Dependencies are declared as final fields, ensuring they’re set once and never modified.
  2. Mandatory Dependencies: Guarantees that the class cannot be instantiated without its required dependencies.
  3. Testability: Simplifies unit testing—no need for reflection or Spring context to inject mocks.

How to Implement It

@Service
public class OrderService {  
    // Declare dependencies as final  
    private final PaymentGateway paymentGateway;  
    private final InventoryService inventoryService;  

    // Single constructor: Spring automatically injects dependencies here  
    public OrderService(PaymentGateway paymentGateway, InventoryService inventoryService) {  
        this.paymentGateway = paymentGateway;  
        this.inventoryService = inventoryService;  
    }  
}

Key Scenarios for Constructor Injection

  • Mandatory Dependencies: Use when a class cannot function without specific collaborators (e.g., a service needing a repository).
  • Thread Safety: Immutable dependencies prevent race conditions in multi-threaded environments.

Best Practices

  • Avoid @Autowired: In Spring Boot 4.3+, a single constructor doesn’t require @Autowired—it’s inferred.
  • Order Dependencies Logically: Place critical dependencies first in the constructor signature.

2. Setter Injection: Flexibility with Caution

What Is Setter Injection?

Dependencies are injected via setter methods (e.g., setPaymentGateway()). Unlike constructor injection, dependencies are optional and mutable.

When to Use It

  • Optional Dependencies: Rare cases where a bean can operate without a dependency (e.g., a fallback mechanism).
  • Reconfigurable Beans: Dynamically swapping dependencies at runtime (e.g., feature toggles).

Implementation Example

@Service
public class NotificationService {  
    private EmailSender emailSender;  

    // Setter method with @Autowired  
    @Autowired  
    public void setEmailSender(EmailSender emailSender) {  
        this.emailSender = emailSender;  
    }  
}

Risks of Setter Injection

  • Temporal Coupling: The class may be instantiated without its dependencies, leading to NullPointerExceptions.
  • Mutability: Setters allow dependencies to change post-initialization, risking inconsistent states.

3. Field Injection: A Legacy Antipattern

What Is Field Injection?

Dependencies are injected directly into fields using @Autowired, bypassing constructors or setters.

Why It’s Discouraged

  • Hidden Dependencies: Fields are private, making dependencies invisible to the class’s public API.
  • Testability Nightmare: Requires Spring context or reflection to inject mocks in tests.
  • Immutability Impossible: Fields can’t be final, allowing unintended modifications.

Example (Not Recommended)

@Service  
public class ReportService {  
    @Autowired  // Avoid this!  
    private DataFormatter formatter;  
}

When (If Ever) to Use It

  • Legacy Code: Only in existing codebases where refactoring isn’t feasible.
  • Prototyping: Temporary experiments—never in production.

Use Case: Mandatory dependencies.

Why It Wins:

  • Enforces immutability (fields marked final).
  • Eliminates NullPointerExceptions by ensuring dependencies are set at instantiation.
  • Simplifies testing (no reflection hacks).
@Service
public class OrderService {  
    private final PaymentGateway paymentGateway;  
    // Spring automatically injects the dependency here  
    public OrderService(PaymentGateway paymentGateway) {  
        this.paymentGateway = paymentGateway;  
    }  
}

2. Setter Injection: For Optional Dependencies

Use Case: Rare scenarios with optional or reconfigurable dependencies.

Pitfalls:

  • Breaks immutability.
  • Allows incomplete object states if setters aren’t called.
@Service
public class CachingService {  
    private CacheManager cacheManager;  
    // Setter isn't called automatically unless you use @Autowired  
    @Autowired  
    public void setCacheManager(CacheManager cacheManager) {  
        this.cacheManager = cacheManager;  
    }  
}

3. Field Injection: Proceed with Caution

Why It’s Discouraged:

  • Hides dependencies (they’re not visible in the public API).
  • Tightly couples code to Spring (hard to instantiate classes manually).
  • Breaks immutability.
@Service
public class ReportService {  
    @Autowired  // Avoid this!  
    private DataFormatter formatter;  
}

Key Takeaway: Prefer constructor injection for 90% of use cases.

Bean Scopes in Real-World Applications

Singleton vs. Prototype: Choosing Wisely

  • Singleton (Default): One instance per Spring container. Ideal for stateless services (e.g., utilities, repositories).
  • Prototype: New instance every time the bean is requested. Use for stateful objects (e.g., user sessions, request processors).
@Scope(“prototype”)
@Component  
public class ShoppingCart {  
    // Stateful: Holds items for a specific user  
    private List<Item> items = new ArrayList<>();  
}

Web-Aware Scopes: Request, Session and Beyond

  • @RequestScope: Beans tied to an HTTP request (e.g., request metadata).
  • @SessionScope: Beans tied to a user session (e.g., authentication context).
@RequestScope
@Component  
public class ApiRequestContext {  
    private String clientId;  
    private String authToken;  
}

Thread Safety

Problem: Singleton beans with mutable state can cause race conditions in multi-threaded environments (e.g., web apps).
Solution:

  • Avoid mutable fields in singletons.
  • Use ThreadLocal or prototype scope for stateful operations.

Circular Dependencies

How Circular Dependencies Happen

Bean A → Depends on → Bean B → Depends on → Bean A.
Spring throws BeanCurrentlyInCreationException by default (post-Spring Boot 2.6).

Fixes That Actually Work

  1. Refactor with a Third Bean:
    Extract shared logic into Bean C, breaking the cycle.
  2. Use @Lazy:
    Delay initialization of one bean.
    @Service  
    public class OrderService {  
        private final PaymentService paymentService;  
        // Delay PaymentService initialization  
        public OrderService(@Lazy PaymentService paymentService) {  
            this.paymentService = paymentService;  
        }  
    }
  3. Switch to Constructor Injection:
    Breaks cycles by enforcing clear dependency order.

Nuclear Option (Not Recommended):

spring.main.allow-circular-references=true  

Dependency Injection Patterns for Clean Architecture

1. Strategy Pattern: Swapping Implementations Seamlessly

Define an interface, inject multiple implementations, and use @Qualifier to choose:

public interface PaymentStrategy {
    void processPayment(double amount);  
}  

@Component("creditCardStrategy")  
public class CreditCardStrategy implements PaymentStrategy { /* ... */ }  

@Component("paypalStrategy")  
public class PayPalStrategy implements PaymentStrategy { /* ... */ }  

@Service  
public class PaymentService {  
    private final PaymentStrategy strategy;  
    // Inject a specific strategy  
    public PaymentService(@Qualifier("creditCardStrategy") PaymentStrategy strategy) {  
        this.strategy = strategy;  
    }  
}

2. Factory Pattern with @Bean Methods

Create complex objects conditionally:

@Configuration
public class FactoryConfig {  
    @Bean  
    @ConditionalOnProperty(name = "notification.enabled", havingValue = "true")  
    public NotificationService notificationService() {  
        return new AwsSmsService();  
    }  
}

3. Adapter Pattern: Integrating Legacy Code

Wrap non-Spring components into Spring-managed beans:

@Configuration
public class LegacyAdapter {  
    @Bean  
    public ThirdPartyLogger thirdPartyLogger() {  
        return new ThirdPartyLoggerAdapter();  
    }  
}

Performance Tuning: Eager vs Lazy Initialization

Eager (Default)

  • Pros: Fail-fast (issues surface at startup).
  • Cons: Slower startup times.

@Lazy Initialization

  • Pros: Faster startup for rarely used beans.
  • Cons: Runtime delays when the bean is first accessed.

When to Use @Lazy:

  • Large beans (e.g., data connectors).
  • Beans used only in specific profiles.
@Lazy
@Service  
public class HeavyReportGenerator { /* ... */ }

Testing: DI in Unit and Integration Tests

1. Mocking Dependencies with @MockBean

Replace real beans with mocks in tests:

@SpringBootTest
public class OrderServiceTest {  
    @MockBean  
    private PaymentGateway paymentGateway;  // Replaces the real bean  
    @Autowired  
    private OrderService orderService;  
}

2. Testing Configuration Classes

Verify that beans are wired correctly:

@SpringBootTest
public class AppConfigTest {  
    @Autowired  
    private DataSource dataSource;  
    @Test  
    void testDataSourceIsConfigured() {  
        assertThat(dataSource).isInstanceOf(HikariDataSource.class);  
    }  
}

Pitfalls and How to Avoid Them

1. Overusing @ComponentScan

Problem: Scanning too many packages slows startup.
Fix: Explicitly define scan paths:

@SpringBootApplication(scanBasePackages = “com.yourdomain.core”)

2. Ignoring Bean Lifecycle Hooks

Solution: Use @PostConstruct for setup logic:

@PostConstruct
public void init() {  
    // Validate config or preload data  
}

3. Misusing @Primary and @Qualifier

  • @Primary: Marks a default bean when multiple candidates exist.
  • @Qualifier: Selects a specific bean by name.

Frequently Asked Questions (FAQs)


Field Injection hides dependencies, tightly couples your code to Spring, and breaks immutability. It also complicates unit testing, as dependencies can only be injected via reflection or Spring context, making it an anti-pattern for production code.


Constructor Injection ensures immutability, enforces mandatory dependencies, and simplifies testing by avoiding reflection or Spring context for mocks.


Circular dependencies can be resolved by refactoring with a third bean, using @Lazy to delay initialization, or switching to constructor injection.


Singleton creates one instance per Spring container, ideal for stateless services. Prototype creates a new instance each time, suitable for stateful objects.


Use @Lazy for large or rarely used beans to improve startup performance, but be cautious of runtime delays when the bean is first accessed.

Mastering dependency injection in Spring Boot isn’t about memorizing annotations for your web services, it’s about designing systems that are resilient, testable, and performant. As a senior developer, focus on:

  1. Intentional Design: Use constructor injection, avoid circular dependencies.
  2. Strategic Bean Management: Leverage scopes and conditions.
  3. Rigorous Testing: Validate wiring and edge cases.

By embracing these principles, you’ll transform DI from a framework feature into a cornerstone of clean architecture. Happy coding!