Why constructor injection is preferred over field injection in Spring Boot — covering testability, immutability, required dependencies, and what @Autowired on a field actually costs you.
Spring supports three forms of dependency injection: field injection (@Autowired on a field), setter injection, and constructor injection. Field injection is the most common in codebases that learned Spring from older tutorials. It is also the form that Intellij flags with a warning and the Spring team has discouraged for years. Understanding why makes you write better-structured components, not just follow a style rule.
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private BetfairClient betfairClient;
public void placeOrder(OrderRequest req) {
betfairClient.place(req);
orderRepository.save(Order.from(req));
}
}
This works. Spring sets the fields via reflection after construction. The problem is what this design allows — and what it prevents.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final BetfairClient betfairClient;
public OrderService(OrderRepository orderRepository, BetfairClient betfairClient) {
this.orderRepository = Objects.requireNonNull(orderRepository);
this.betfairClient = Objects.requireNonNull(betfairClient);
}
public void placeOrder(OrderRequest req) {
betfairClient.place(req);
orderRepository.save(Order.from(req));
}
}
Since Spring 4.3, a single-constructor class does not need @Autowired at all. Spring injects through the constructor automatically.
1. Dependencies are final. Field injection cannot use final. Constructor injection makes every dependency immutable after construction — they cannot be swapped out, nulled, or replaced by a misbehaving test setup.
2. The class is testable without a Spring context. With field injection, you must either spin up a Spring context or use reflection to inject mocks. With constructor injection, test setup is:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository orderRepository;
@Mock BetfairClient betfairClient;
OrderService underTest;
@BeforeEach
void setUp() {
underTest = new OrderService(orderRepository, betfairClient);
}
}
No Spring, no @SpringBootTest, no application context. The test starts in milliseconds.
3. Missing dependencies fail at startup. With field injection, if the BetfairClient bean is missing, the application starts and fails on the first call to placeOrder(). With constructor injection, the context fails to start immediately — the missing dependency is caught at deployment, not at runtime.
4. Circular dependencies are exposed. If class A depends on B and B depends on A via constructor injection, Spring throws BeanCurrentlyInCreationException at startup. The circular dependency cannot hide. With field injection, Spring resolves circular dependencies by injecting an incompletely initialised proxy — the bug is hidden until a specific code path triggers it.
5. The constructor documents the contract. The constructor signature is the explicit list of what the class needs to function. New developers reading the code see the dependencies immediately, without scanning every field for @Autowired.
Setter injection is correct for optional dependencies — those with a sensible default when absent:
@Service
public class MarketDataService {
private final MarketRepository repository;
private CacheManager cacheManager; // optional
public MarketDataService(MarketRepository repository) {
this.repository = Objects.requireNonNull(repository);
}
@Autowired(required = false)
public void setCacheManager(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
}
This makes the dependency structure clear: repository is required (constructor), cacheManager is optional (setter). Use this pattern sparingly — most “optional” dependencies should be represented differently, such as a no-op default implementation.
With Lombok, constructor injection becomes as concise as field injection:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final BetfairClient betfairClient;
public void placeOrder(OrderRequest req) {
betfairClient.place(req);
orderRepository.save(Order.from(req));
}
}
@RequiredArgsConstructor generates a constructor for all final fields. The fields are final, the constructor is there, and there is no boilerplate. This is the most common form in modern Spring Boot codebases.
Field injection feels concise because it removes the constructor. But the constructor is where the contract lives. Removing it hides:
@Autowired fields are invisible)A class with six @Autowired fields and no constructor is hard to test, hard to reason about, and hard to refactor. The same class with constructor injection makes the problem obvious — a constructor with six parameters is a prompt to split the class.
@Configuration classes are proxied by Spring, and there are edge cases where constructor injection on a @Configuration class behaves differently than expected. For @Configuration classes that only declare @Bean methods and take no external dependencies, field injection via @Value or @Autowired is acceptable and conventional. For @Configuration classes that delegate to injected services, constructor injection still applies.
Switching to constructor injection is a low-effort, high-signal change. Every class you convert becomes easier to test and easier to reason about independently of the container.
If you’re working on Spring Boot application architecture and want a review, get in touch.