Software Engineer · Java · Spring Boot · Microservices
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
- Why Dependency Injection Is Misunderstood in Spring
- Constructor Injection vs Field Injection: The Real Difference
- @Autowired, @Qualifier, and @Primary: When to Use What
- Circular Dependency Traps and How to Break Them
- Lazy Initialization and @Lazy
- ApplicationContext vs BeanFactory: When to Inject the Context
- Designing Testable Spring Components
- Real Production Failure Scenarios
- Key Takeaways
- 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.
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
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;
}
}
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.
spring.main.allow-circular-references=false (default in Spring Boot 2.6+) to fail fast on circular dependencies during development.
9. Key Takeaways
- Use constructor injection for all mandatory dependencies — it enables testing without Spring.
- Field injection is an anti-pattern — it hides dependencies and prevents immutability.
- Circular dependencies indicate design problems — fix the design, don't just work around it.
- Use @Primary for defaults, @Qualifier for specific needs — be intentional about multiple implementations.
- Design for testability — if you can't test without Spring, your DI is wrong.
- Inject abstractions over concretions — depend on interfaces, not implementations.
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 HereDiscussion / Comments
Related Posts
Last updated: March 2026 — Written by Md Sanwar Hossain