Spring Boot – Dependency Injection

June 21, 20264 min readUpdated 8/18/2026

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 testablenew 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:

  1. 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.
  2. Publish an event instead of calling back. If OrderService needs NotificationService to 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.
  3. Inject an ObjectProvider on 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=true

It 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, final fields, @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.
  • ObjectProvider for 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.