Dependency Injection in Spring Boot - software architecture patterns
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Software Dev March 22, 2026 14 min read Java Performance Engineering Series

Dependency Injection Patterns in Spring Boot: Constructor Injection, Circular Deps & Testability

Dependency Injection is the heart of Spring, but most developers use it poorly — field injection everywhere, mysterious circular dependency errors, and untestable components. Understanding the difference between injection styles, knowing when to use @Qualifier vs @Primary, and designing for testability transforms your Spring Boot applications from fragile to robust. This guide covers DI patterns that actually work in production microservices.

Table of Contents

  1. Why Dependency Injection Is Misunderstood in Spring
  2. Constructor Injection vs Field Injection: The Real Difference
  3. @Autowired, @Qualifier, and @Primary: When to Use What
  4. Circular Dependency Traps and How to Break Them
  5. Lazy Initialization and @Lazy
  6. ApplicationContext vs BeanFactory: When to Inject the Context
  7. Designing Testable Spring Components
  8. Real Production Failure Scenarios
  9. Key Takeaways
  10. Conclusion

1. Why Dependency Injection Is Misunderstood in Spring

Dependency Injection is not about Spring annotations — it's about inverting control. Your class doesn't create its dependencies; they're provided from outside. This enables loose coupling, testability, and flexibility. Spring just makes it convenient with @Autowired, but the principle exists independently of any framework.

The misunderstanding starts when developers treat @Autowired as magic. They sprinkle it on fields without understanding the lifecycle, then wonder why tests require a full Spring context, why circular dependencies appear, and why refactoring is painful.

The fundamental question: Can you instantiate your class with new ClassName(...) in a unit test, passing mock dependencies? If no, your DI design is broken.

2. Constructor Injection vs Field Injection: The Real Difference

Field injection is convenient; constructor injection is correct. Here's why:

// ❌ Field injection - common but problematic
@Service
public class OrderService {
    @Autowired
    private CustomerRepository customerRepository;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private NotificationService notificationService;
}
// Problems:
// 1. Cannot instantiate without Spring - untestable
// 2. Dependencies are hidden - not visible in constructor signature
// 3. Allows partial initialization - NPE risk
// 4. Immutability impossible - fields can't be final
// 5. Easy to add too many dependencies - design smell hidden
// ✅ Constructor injection - explicit and testable
@Service
public class OrderService {
    private final CustomerRepository customerRepository;
    private final PaymentService paymentService;
    private final NotificationService notificationService;
    
    // @Autowired optional since Spring 4.3 for single constructor
    public OrderService(
            CustomerRepository customerRepository,
            PaymentService paymentService,
            NotificationService notificationService) {
        this.customerRepository = customerRepository;
        this.paymentService = paymentService;
        this.notificationService = notificationService;
    }
}
// Benefits:
// 1. Testable: new OrderService(mockRepo, mockPayment, mockNotification)
// 2. Dependencies explicit in signature
// 3. Immutable: fields are final
// 4. Too many dependencies = too many constructor params = obvious smell
Spring recommendation: The Spring team officially recommends constructor injection for mandatory dependencies. Use setter injection only for optional dependencies that have reasonable defaults.

3. @Autowired, @Qualifier, and @Primary: When to Use What

When multiple beans implement the same interface, Spring doesn't know which to inject. @Qualifier and @Primary resolve this ambiguity differently:

// Interface with multiple implementations
public interface PaymentProcessor {
    void process(Payment payment);
}
@Component("stripeProcessor")
public class StripePaymentProcessor implements PaymentProcessor { ... }
@Component("paypalProcessor")
public class PayPalPaymentProcessor implements PaymentProcessor { ... }
@Component("squareProcessor")
@Primary  // Default when no qualifier specified
public class SquarePaymentProcessor implements PaymentProcessor { ... }
// Using @Primary - injects Square by default
@Service
public class CheckoutService {
    private final PaymentProcessor processor; // Gets SquarePaymentProcessor
    
    public CheckoutService(PaymentProcessor processor) {
        this.processor = processor;
    }
}
// Using @Qualifier - explicitly choose implementation
@Service
public class RefundService {
    private final PaymentProcessor processor;
    
    public RefundService(@Qualifier("stripeProcessor") PaymentProcessor processor) {
        this.processor = processor;
    }
}
// Injecting all implementations
@Service
public class PaymentRouter {
    private final List<PaymentProcessor> processors; // All implementations
    
    public PaymentRouter(List<PaymentProcessor> processors) {
        this.processors = processors;
    }
}

When to use which: Use @Primary when one implementation is the default for most cases. Use @Qualifier when specific implementations are needed in specific places. Use List<Interface> injection when you need all implementations (strategy pattern).

4. Circular Dependency Traps and How to Break Them

Circular dependencies occur when A depends on B, and B depends on A (directly or through a chain). With constructor injection, Spring fails fast at startup. With field injection, it creates a proxy — masking the design problem:

// Circular dependency - ServiceA needs ServiceB, ServiceB needs ServiceA
@Service
public class OrderService {
    private final CustomerService customerService; // CustomerService needs OrderService!
    
    public OrderService(CustomerService customerService) {
        this.customerService = customerService;
    }
}
@Service
public class CustomerService {
    private final OrderService orderService; // Circular!
    
    public CustomerService(OrderService orderService) {
        this.orderService = orderService;
    }
}
// Result: BeanCurrentlyInCreationException at startup
// ✅ Solution 1: Extract shared logic into third service
@Service
public class OrderCustomerLinkService {
    // Logic that both services need
}
// ✅ Solution 2: Use events to decouple
@Service
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;
    
    public void createOrder(Order order) {
        // Process order
        eventPublisher.publishEvent(new OrderCreatedEvent(order));
    }
}
@Service
public class CustomerService {
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // React to order creation without direct dependency
    }
}
// ✅ Solution 3: Lazy injection (use sparingly)
@Service
public class OrderService {
    private final Lazy<CustomerService> customerService;
    
    public OrderService(@Lazy Lazy<CustomerService> customerService) {
        this.customerService = customerService;
    }
}
Design smell: Circular dependencies usually indicate a design problem — two services are too tightly coupled or should be one service. Fix the design, don't just work around the error.

5. Lazy Initialization and @Lazy

@Lazy defers bean creation until first use. Use it for: breaking circular dependencies (temporarily), expensive beans that may not be used, and speeding up application startup:

// Bean created on first getHeavyService() call, not at startup
@Service
@Lazy
public class HeavyReportingService {
    public HeavyReportingService() {
        // Expensive initialization - database connections, file loading, etc.
    }
}
// Lazy injection - HeavyService created when first accessed
@Service
public class DashboardService {
    private final HeavyReportingService reportingService;
    
    public DashboardService(@Lazy HeavyReportingService reportingService) {
        this.reportingService = reportingService; // Proxy injected, not real bean
    }
    
    public Report generateReport() {
        return reportingService.generate(); // Real bean created here
    }
}
// Global lazy initialization (Spring Boot 2.2+)
// application.yml
spring:
  main:
    lazy-initialization: true  # All beans lazy by default

6. ApplicationContext vs BeanFactory: When to Inject the Context

Sometimes you need dynamic bean access. Injecting ApplicationContext is valid, but overuse is an anti-pattern:

// ✅ Valid use: Dynamic bean lookup based on runtime condition
@Service
public class ProcessorFactory {
    private final ApplicationContext context;
    
    public ProcessorFactory(ApplicationContext context) {
        this.context = context;
    }
    
    public PaymentProcessor getProcessor(PaymentMethod method) {
        String beanName = method.name().toLowerCase() + "Processor";
        return context.getBean(beanName, PaymentProcessor.class);
    }
}
// ❌ Anti-pattern: Using context to avoid constructor injection
@Service
public class BadService {
    @Autowired
    private ApplicationContext context;
    
    public void doSomething() {
        // This defeats the purpose of DI!
        CustomerService cs = context.getBean(CustomerService.class);
        cs.findCustomer();
    }
}
// ✅ Better: ObjectProvider for optional/lazy beans
@Service
public class NotificationService {
    private final ObjectProvider<SlackClient> slackClient;
    
    public NotificationService(ObjectProvider<SlackClient> slackClient) {
        this.slackClient = slackClient; // May not exist in all environments
    }
    
    public void notify(String message) {
        slackClient.ifAvailable(client -> client.send(message));
    }
}

7. Designing Testable Spring Components

The ultimate test of good DI design: can you test without Spring? Constructor injection makes this possible:

// Service designed for testability
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
    private final Clock clock; // Inject clock for time-based testing!
    
    public OrderService(
            OrderRepository orderRepository,
            PaymentService paymentService,
            Clock clock) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
        this.clock = clock;
    }
    
    public Order createOrder(OrderRequest request) {
        Order order = new Order();
        order.setCreatedAt(LocalDateTime.now(clock)); // Testable!
        // ...
        return orderRepository.save(order);
    }
}
// Unit test - NO SPRING CONTEXT NEEDED
class OrderServiceTest {
    private OrderService orderService;
    private OrderRepository mockRepository;
    private PaymentService mockPaymentService;
    private Clock fixedClock;
    
    @BeforeEach
    void setUp() {
        mockRepository = mock(OrderRepository.class);
        mockPaymentService = mock(PaymentService.class);
        fixedClock = Clock.fixed(Instant.parse("2026-03-22T10:00:00Z"), ZoneOffset.UTC);
        
        // No Spring! Just plain Java
        orderService = new OrderService(
            mockRepository, 
            mockPaymentService, 
            fixedClock
        );
    }
    
    @Test
    void createsOrderWithCurrentTimestamp() {
        when(mockRepository.save(any())).thenAnswer(i -> i.getArgument(0));
        
        Order order = orderService.createOrder(new OrderRequest());
        
        assertThat(order.getCreatedAt())
            .isEqualTo(LocalDateTime.of(2026, 3, 22, 10, 0, 0));
    }
}
"Dependency Injection is not a goal unto itself. The goal is to write code that is easy to understand, test, and maintain. DI is just a technique that helps achieve those goals when used correctly."
— Rod Johnson, Spring Framework Creator

8. Real Production Failure Scenarios

Startup failure from circular dependency: Application fails to start in production because a new service introduced a cycle. Fix: Use Spring Boot's debug logging (logging.level.org.springframework=DEBUG) to trace bean creation order.

NoUniqueBeanDefinitionException: Added a new interface implementation, forgot @Primary. All injection points fail. Fix: Review all implementations when adding new ones.

NullPointerException from field injection: Bean used before @Autowired fields initialized (e.g., in constructor or field initializer). Fix: Switch to constructor injection — problem becomes compile-time error.

Prevention: Enable spring.main.allow-circular-references=false (default in Spring Boot 2.6+) to fail fast on circular dependencies during development.

9. Key Takeaways

10. Conclusion

Dependency Injection done right is invisible — your code is clean, testable, and maintainable. DI done wrong creates a tangled mess where every change risks breaking something, tests require full application contexts, and circular dependencies lurk around every corner.

The shift from field injection to constructor injection is the single highest-impact change you can make. Your dependencies become explicit, your classes become immutable, and your tests become fast. Start enforcing it in code reviews, configure your IDE to warn on field injection, and gradually refactor existing code. The result is Spring Boot applications that are a joy to maintain rather than a burden to inherit.

Explore More Articles

Discover more in-depth technical guides on Spring Boot, Java, and software engineering.

Read Full Blog Here

Discussion / Comments

Related Posts

Software Dev

Spring Boot Microservices

Build production-ready microservices with Spring Boot patterns.

Software Dev

Clean Architecture

Design maintainable systems with clean architecture principles.

Software Dev

SOLID Principles in Java

Master SOLID principles for better object-oriented design.

Last updated: March 2026 — Written by Md Sanwar Hossain