Dependency injection in Spring Boot in Java & Spring Boot
If you've written Spring code, you've probably seen different ways to wire dependencies into your classes. Some work well in tests, others leave you fighting with reflection and mocks. This guide cuts through the noise and shows you the patterns that keep your codebase testable and maintainable at scale.
Why dependency injection matters
Dependency injection (DI) is how Spring manages object creation and assembly. Instead of your class instantiating its own dependencies, Spring injects them. This sounds simple, but the way you structure those injections determines whether your code is easy to test, refactor, and reason about.
We've learned this the hard way. On a high-volume e-commerce platform, we had a service layer where dependencies were scattered across field injection, constructor injection, and static factory methods. When tests started flaking and mock setups became nightmarish, we standardized on constructor injection. Tests became faster to write and more reliable.
Use constructor injection — Dependencies are explicit, final and easy to pass in tests
Constructor injection is the pattern to default to. Your dependencies become constructor parameters, which means they're explicit, immutable (when you use final), and straightforward to mock in tests.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
private final PricingEngine pricingEngine;
public OrderService(OrderRepository orderRepository,
NotificationService notificationService,
PricingEngine pricingEngine) {
this.orderRepository = orderRepository;
this.notificationService = notificationService;
this.pricingEngine = pricingEngine;
}
public Order createOrder(OrderRequest request) {
BigDecimal price = pricingEngine.calculate(request);
Order order = new Order(request.getCustomerId(), price);
Order saved = orderRepository.save(order);
notificationService.notifyOrderCreated(saved);
return saved;
}
}When Spring sees a class with a single constructor, it automatically wires beans into it. No annotation needed. This clarity is invaluable: anyone reading the code knows exactly what this service needs to function.
Testing becomes trivial:
@Test
void testCreateOrderNotifiesCustomer() {
OrderRepository mockRepo = mock(OrderRepository.class);
NotificationService mockNotif = mock(NotificationService.class);
PricingEngine mockPricing = mock(PricingEngine.class);
OrderService service = new OrderService(mockRepo, mockNotif, mockPricing);
// Arrange
when(mockPricing.calculate(any())).thenReturn(BigDecimal.valueOf(99.99));
Order savedOrder = new Order(1L, BigDecimal.valueOf(99.99));
when(mockRepo.save(any())).thenReturn(savedOrder);
// Act
OrderRequest request = new OrderRequest(1L, "Item");
Order result = service.createOrder(request);
// Assert
verify(mockNotif).notifyOrderCreated(savedOrder);
assertEquals(savedOrder, result);
}You instantiate the service directly, pass your mocks, and test. No Spring context needed for the unit test. No reflection wizardry. This is fast and reliable.
Avoid @Autowired fields — Field injection hides dependencies and needs reflection to test
Field injection looks convenient at first:
@Service
public class PaymentProcessor {
@Autowired
private PaymentGateway gateway;
@Autowired
private AuditService auditService;
public void processPayment(Payment payment) {
gateway.charge(payment);
auditService.log(payment);
}
}Spring uses reflection to inject these dependencies after instantiation. The problem: your class hides its dependencies. Someone reading the code might not realize this service needs a PaymentGateway. It's only when you try to instantiate it for testing that Spring isn't there, and you're stuck.
To test field-injected classes, you either use Spring's test context (slower) or reach for reflection-based tools like ReflectionTestUtils:
@SpringBootTest
class PaymentProcessorTest {
@Autowired
private PaymentProcessor processor;
@MockBean
private PaymentGateway mockGateway;
@MockBean
private AuditService mockAudit;
@Test
void testProcessPayment() {
// Test runs, but you're spinning up the full Spring context
// for a simple unit test
Payment payment = new Payment(100, "USD");
processor.processPayment(payment);
verify(mockGateway).charge(payment);
}
}This test boots the entire Spring application context. On a large codebase with dozens of services, this adds seconds or minutes to every test run. On a trading platform where we had thousands of unit tests, this overhead was unacceptable.
Constructor injection avoids this entirely. Make dependencies parameters, and you can test without Spring.
Interfaces when they earn it — Introduce an interface for multiple implementations or boundaries, not for every class
The urge to create an interface for every service is strong, especially in enterprise environments. Resist it. Interfaces introduce indirection and complexity. Use them when you have a genuine reason: multiple implementations, a clear architectural boundary, or a behavior you want to swap.
On a ride-hailing platform, we had multiple payment processors (Stripe, PayPal, Square). An interface made sense:
public interface PaymentProcessor {
PaymentResult process(PaymentRequest request);
}
@Service
public class StripePaymentProcessor implements PaymentProcessor {
private final StripeClient client;
public StripePaymentProcessor(StripeClient client) {
this.client = client;
}
@Override
public PaymentResult process(PaymentRequest request) {
StripeCharge charge = client.charge(request.getAmount());
return new PaymentResult(charge.getId(), charge.getStatus());
}
}
@Service
public class PayPalPaymentProcessor implements PaymentProcessor {
private final PayPalClient client;
public PayPalPaymentProcessor(PayPalClient client) {
this.client = client;
}
@Override
public PaymentResult process(PaymentRequest request) {
PayPalTransaction txn = client.execute(request.getAmount());
return new PaymentResult(txn.getId(), txn.getStatus());
}
}Your checkout service depends on the interface, not the concrete class. At runtime, Spring chooses which implementation to use. This is powerful and testable.
But for a simple service with no variants? Skip the interface. It adds ceremony without benefit. We've seen codebases where every single service had an interface that was never implemented elsewhere. It's just noise.
Qualifiers for multiple beans — Use @Qualifier or a Map to choose implementations at runtime
When you have multiple implementations of an interface, Spring needs to know which one to inject. Qualifiers let you name beans and request them explicitly:
@Service
@Qualifier("stripe")
public class StripePaymentProcessor implements PaymentProcessor {
// ...
}
@Service
@Qualifier("paypal")
public class PayPalPaymentProcessor implements PaymentProcessor {
// ...
}
@Service
public class CheckoutService {
private final PaymentProcessor processor;
public CheckoutService(@Qualifier("stripe") PaymentProcessor processor) {
this.processor = processor;
}
public void checkout(Order order) {
processor.process(new PaymentRequest(order.getTotal()));
}
}For more dynamic scenarios, inject all implementations as a Map:
@Service
public class PaymentRouter {
private final Map processors;
public PaymentRouter(Map processors) {
this.processors = processors;
}
public PaymentResult route(String provider, PaymentRequest request) {
PaymentProcessor processor = processors.get(provider);
if (processor == null) {
throw new IllegalArgumentException("Unknown provider: " + provider);
}
return processor.process(request);
}
}Spring automatically populates the Map with all beans that implement the interface, keyed by bean name. This pattern works well when you want to choose implementations at runtime based on configuration or user input.
Common mistakes
Mixing injection styles in one project. We've seen teams with constructor injection in some services, field injection in others, setter injection scattered throughout. This inconsistency makes onboarding harder and introduces bugs. Pick one pattern and enforce it via code review and linting rules.
Circular dependencies. Constructor injection exposes a problem that field injection hides: circular dependencies. If Service A needs Service B and Service B needs Service A, your code won't even start. This is a design problem, not a framework problem. Break the cycle by extracting a third service or using events.
// Bad: A and B depend on each other
@Service
public class ServiceA {
public ServiceA(ServiceB b) { }
}
@Service
public class ServiceB {
public ServiceB(ServiceA a) { }
}
// Better: Extract a mediator or use events
@Service
public class ServiceMediator {
private final ServiceA a;
private final ServiceB b;
public ServiceMediator(ServiceA a, ServiceB b) {
this.a = a;
this.b = b;
}
}Injecting configuration as beans. Configuration belongs in properties or YAML, not as beans. Spring's @Value and @ConfigurationProperties are cleaner. If you find yourself injecting a config object everywhere, centralize it.
Lazy initialization without thought. Spring's @Lazy defers bean creation until first use. Occasionally useful, but often a sign that your bean graph is too complex. If you need @Lazy everywhere, reconsider your design.
Key takeaways
- Constructor injection is the default. Make dependencies explicit parameters. It's testable, clear, and catches circular dependencies early.
- Avoid field injection. It hides dependencies, forces Spring for tests, and makes code harder to understand at a glance.
- Use interfaces strategically. Introduce them for multiple implementations or architectural boundaries, not for ceremony.
- Qualifiers handle multiple beans. Name your implementations with
@Qualifieror inject as a Map for dynamic selection. - Consistency matters. Pick a DI pattern for your team and stick with it. Code reviews and linting help enforce this.
- Test without Spring when possible. Constructor injection lets you unit test services in isolation, keeping test suites fast and reliable.






Comments
Loading comments…