Inversion of control sounds abstract and is not. A class that needs a collaborator normally builds one; with IoC it declares that it needs one and something else supplies it. The container does the supplying. That is dependency injection, and in practice it means one thing: use constructor injection.
The problem it solves
Without injection, a class builds what it needs:
public class OrderService {
private final ProductDAO productDAO = new ProductDAOImp();
private final StripeService stripe = new StripeService();
// …
}This compiles and it is rigid. OrderService now knows the concrete class of both
collaborators, decides their lifecycle, and cannot be tested without a real Stripe client. There is
no seam.
With injection, it asks:
@Service
public class OrderService {
private final ProductDAO productDAO;
private final StripeService stripe;
public OrderService(ProductDAO productDAO, StripeService stripe) {
this.productDAO = productDAO;
this.stripe = stripe;
}
}Now it depends on an interface, the container decides the implementation, and a test hands it two
mocks with a plain new.
The three styles, and which to use
Constructor injection — the default
@Service
@RequiredArgsConstructor
public class MailServiceImpl implements MailService {
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final TemplateEngine templateEngine;
private final PizzaProperties properties;
}Three properties make this the right default:
- The fields can be
final. The object is fully built or not built at all — there is no window where a dependency is null. - Dependencies are visible. A constructor with nine parameters is embarrassing, and that is useful information: the class is doing too much. Field injection hides the same nine and the class quietly grows.
- It is trivially testable —
new MailServiceImpl(a, b, c), no container, no reflection.
Since Spring 4.3, @Autowired is not needed on a single constructor.
Lombok's @RequiredArgsConstructor generates one from the final fields, which
removes the boilerplate that made people reach for field injection in the first place.
Field injection — avoid in new code
@Service
public class ProductServiceImpl implements ProductService {
@Autowired
private ProductDAO productDAO;
@Autowired
private EntityDTOMapper mapper;
}Shorter, and worse. The fields cannot be final; the class cannot be constructed
correctly without reflection, so a unit test needs Spring or
ReflectionTestUtils; and there is no pressure against adding a tenth dependency.
The pizza API uses this style in its older services and constructor injection in the newer ones. That inconsistency is honest — it is what a real codebase looks like mid-migration — but for new code there is no argument for it.
Setter injection — for genuinely optional dependencies
Rare. If a dependency is truly optional, an ObjectProvider in the constructor
expresses it better, which is what the pizza API does for its optional integrations:
private final ObjectProvider<JavaMailSender> mailSenderProvider;
public void sendOrderConfirmation(OrderDTO order) {
JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (sender == null) {
log.debug("No mail server configured — skipping");
return;
}
// …
}Boot only creates a JavaMailSender when spring.mail.host is set. As a
required dependency it would stop the whole application from starting on a machine with no mail
configured. ObjectProvider is the container's supported way of saying "give me this if it
exists", and it degrades instead of failing.
Circular dependencies
Two beans that need each other:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| orderService defined in file [OrderServiceImpl.class]
↑ ↓
| notificationService defined in file [NotificationServiceImpl.class]
└─────┘Boot 4 refuses to start on this by default, and that is correct — a cycle is a design problem, not a configuration one. Three real fixes, best first:
- Extract the shared part. Usually both classes want one piece of logic; move it into a third bean they both depend on. The cycle disappears because it was never necessary.
- Publish an event instead of calling back. If
OrderServiceneedsNotificationServiceto react to something, it does not need a reference — it needs to announce that the thing happened. This is what the pizza API does, and lesson 9 covers it. - Inject an
ObjectProvideron one side so resolution is deferred to first use rather than construction.
And the non-fix:
# Makes the error go away and the design problem stay.
spring.main.allow-circular-references=trueIt works by injecting a half-initialised proxy. It is available for migrating legacy code and should not survive into anything new.
@Lazy
@Lazy defers a bean's creation until first use. It is occasionally the right tool —
an expensive bean that is rarely used — and is frequently misused to paper over a cycle or to hide a
slow startup. If startup is slow, find out which bean is slow; deferring it just moves the cost to a
user's first request.
What to take from this
- Constructor injection,
finalfields,@RequiredArgsConstructor. That is the default and it needs no@Autowired. - A long constructor is a design smell you should be able to see — which is precisely what field injection hides.
ObjectProviderfor genuinely optional dependencies, so a missing integration degrades rather than failing startup.- Fix cycles, do not enable them. Extract a third bean or publish an event.
Next: configuration, profiles and properties — where settings come from, which source wins, and why typed records beat scattered strings.