IoC and dependency injection

The problem DI solves, the three injection styles, and the constructor injection that makes a class testable without Spring.

10 min read🌱 Spring Core

Dependency injection is not a Spring feature. It is a way of writing classes that Spring happens to automate, and the distinction matters: the design works without a container, and the container is worth having only because the design is worth having. This lesson starts from the new keyword and the coupling it creates, arrives at injection as the answer, and then goes down a level — into what the container actually does when it wires your objects, why constructor injection is the one to reach for, and what a circular dependency really is.

The new keyword is a decision you cannot take back

A class that constructs its own collaborators has chosen them for everybody.

java
class OrderService {
    private final PricingClient pricing = new HttpPricingClient("https://pricing.internal");
 
    public Order place(Cart cart) { … }
}

OrderService now is an HTTP client. A test needs the network or a stub server. A second environment needs a second URL, so the URL becomes a static, and the static becomes a system property, and by then the class knows about deployment. Nothing here is fixed by an interface — writing PricingClient pricing = new HttpPricingClient(…) still names the implementation at the one place that decides.

The coupling is not "hard to change". It is not mine to change: the caller who knows which pricing client this deployment wants has no way to say so.

Inversion of control

Invert it — the class declares what it needs and somebody else decides what that is:

java
class OrderService {
    private final PricingClient pricing;
 
    OrderService(PricingClient pricing) {      // I need one of these
        this.pricing = pricing;                // I do not care which
    }
}

That is the whole idea, and it is complete without a framework: new OrderService(new HttpPricingClient(url)) in main, a fake in a test. Inversion of control is the general name — the class no longer controls what it collaborates with — and dependency injection is the specific technique of handing the collaborators in.

A container earns its place only when the wiring becomes large. Forty objects with a dozen shared collaborators is a graph somebody has to build in the right order, and rebuilding it by hand in main is a file nobody enjoys. Spring reads the graph off your constructors and builds it.

Three ways in, and they are not equal

java
@Service
class OrderService {
    // 1. Constructor — the only one that can be final
    private final PricingClient pricing;
    OrderService(PricingClient pricing) { this.pricing = pricing; }
 
    // 2. Setter — optional, re-settable
    private AuditLog audit;
    @Autowired(required = false) void setAudit(AuditLog audit) { this.audit = audit; }
 
    // 3. Field — written by reflection, after construction
    @Autowired private Clock clock;
}

Since Spring 4.3 a class with one constructor needs no @Autowired on it at all; the container uses it. That is why modern Spring code has no injection annotations in it, and why @Autowired on a constructor is a sign of an old habit rather than a requirement.

Why constructor injection wins

Four reasons, and the last is the one people underrate.

ConstructorSetter / field
Fields can be finalyesno
Object is usable the moment it existsyesno — there is a window where it is half-built
Dependencies are visible in the signatureyesno — you must read the whole class
Testable without Springnew OrderService(fake)reflection, or a container

That last row is the one that decides it. A field-injected class cannot be constructed in a plain unit test at all — there is no way to supply clock except reflection or starting a context, so people start a context, and a test that took two milliseconds takes four seconds. The cost does not show up when the class is written. It shows up in the test suite, six months later, as a number nobody can explain.

And a fifth, quieter one: a constructor with nine parameters is ugly, and it should be. Field injection makes a nine-dependency class look exactly like a two-dependency class, so the design pressure disappears. The awkwardness is information.

Under the hood: what the container is actually doing

Spring does not construct your graph by following your code. It builds an index first.

A BeanDefinition is metadata — class, scope, constructor arguments, init method — collected from component scanning and @Bean methods before anything is instantiated. Then, for each definition, the container resolves the constructor's parameter types against the definitions it knows, recursing into each one it has not built yet, and caches the finished object in the singleton registry.

Resolution is by type, and ambiguity is a startup failure rather than a runtime surprise: two PricingClient beans and one unqualified injection point stops the application with NoUniqueBeanDefinitionException, naming both candidates. @Primary picks a default; @Qualifier("fast") picks at the injection point; a parameter named fastPricingClient will also match a bean of that name as a last resort, which is the one rule here that depends on compiling with -parameters and is best not relied upon.

Two consequences worth carrying:

  • Startup is a graph traversal, so it fails at startup. A missing collaborator, an ambiguous type and a cycle are all reported before the first request. This is a feature, and it is the reason a Spring application that starts is usually wired correctly.
  • The container builds a bean once and hands the same instance to everyone. Singleton is the default scope, so two collaborators injected with the same type share an object, and any mutable state on it is shared with them.

Circular dependencies, and why constructor injection cannot have them

A needs B, B needs A. With constructors there is no order that works — neither can be built first — and Spring says so and stops.

create Acreate Bneed A againfail at startup
in creationAearly cachefinished

create A. A's constructor needs a B, so A cannot be finished yet. Its name goes into the in-creation set.

1 / 4

With setter or field injection the container has a move available: it instantiates A with its no-argument constructor, puts that half-built reference in an early-exposure cache, finishes B using it, then finishes A. The cycle resolves, silently.

That is not a reason to use field injection. It is a reason to notice that the cycle was never fixed — two classes still cannot exist without each other, and one of them was handed a reference to an object that was not finished yet. Spring Boot 2.6 made this explicit: cycles are rejected by default, and spring.main.allow-circular-references=true exists so that an application with an old one can start while somebody removes it.

The removal is almost always the same shape. A and B both need a third thing; extract it. Or one direction is an event rather than a call; publish it. @Lazy on one side also breaks the cycle by injecting a proxy that resolves on first use — it works, it is occasionally right, and it is more often a way of not doing the extraction.

Walkthrough: the clock that was the same in every test

A team's tests began failing in a batch, always in the afternoon, always the ones asserting on expiresAt.

TokenService had @Autowired private Clock clock;. In production that was a bean from @Bean Clock clock() { return Clock.systemUTC(); } — correct. In tests somebody had, long before, added a TestClock bean to a shared configuration so one test could freeze time. Because the scope was singleton and the configuration was shared, every test in the module got the frozen clock, and the ones that computed a real expiry got the frozen one.

The bug was not the clock. It was that TokenService could not be constructed without a container, so nobody wrote new TokenService(Clock.fixed(…)); they all reached for the shared context, and the shared context had exactly one clock. Constructor injection would have made the per-test clock the obvious path and the shared bean unnecessary.

Try it yourself

Which one starts?

java
@Service class A { A(B b) {} }
@Service class B { B(A a) {} }
 
@Service class C { @Autowired D d; }
@Service class D { @Autowired C c; }

Both pairs are circular. On Spring Boot 3, which application starts, and what changes that answer?

Answer

Neither, by default. A/B cannot be resolved at all — constructor arguments have to be supplied at construction. C/D could be resolved through the early-exposure cache, but since Boot 2.6 circular references are rejected outright, so it fails too, with a message naming the cycle. Setting spring.main.allow-circular-references=true starts C/D and still cannot start A/B. The right fix for both is the same: find the third thing they share, or make one direction an event.

What is injected?

java
interface PricingClient {}
@Component class HttpPricingClient implements PricingClient {}
@Component class CachingPricingClient implements PricingClient {}
 
@Service class OrderService {
    OrderService(PricingClient pricing) {}
}
Answer

Nothing — the application does not start. NoUniqueBeanDefinitionException names both candidates, at startup, before any request. Fix it with @Primary on the one that should be the default, or @Qualifier("cachingPricingClient") at the injection point. Renaming the parameter to cachingPricingClient also works, because the bean name is the fallback match, but it makes the wiring depend on a parameter name that a refactor can silently change.

Why is the field null?

A @Component reads an @Autowired field inside its own constructor and gets null. The same field is fine in every method.

Answer

Field injection happens by reflection after the object is constructed, so during the constructor there is nothing there yet. This is the half-built window that constructor injection does not have. The fix is to take the dependency as a constructor parameter; if the work genuinely has to happen after wiring, @PostConstruct is the callback for it — the next lesson covers where that sits in the lifecycle.

Misconceptions

  • "@Autowired is required." One constructor has needed no annotation since Spring 4.3. Seeing it on a single constructor tells you the code is old, not that it is necessary.
  • "Field injection is fine for tests because you can use @InjectMocks." It works by reflection, which means a renamed field compiles and fails at run time, and the test still cannot document the dependencies in a constructor call.
  • "Constructor injection cannot handle optional dependencies." Optional<AuditLog> and ObjectProvider<AuditLog> are both constructor parameters, and both say "may be absent" in the signature.
  • "A circular dependency means bad luck with class design." It means two classes need each other, which is information. Allowing it hides the information; it does not resolve it.
  • "Singleton means one instance per application." One per container. A test that starts three contexts has three, which is exactly how the frozen clock above went unnoticed for so long.

Going deeper

  • The Spring Framework reference, Core TechnologiesThe IoC Container, particularly "Dependency Resolution Process" and "Circular Dependencies".
  • DefaultListableBeanFactory and AbstractAutowireCapableBeanFactory in the framework source — the in-creation set and the three-level cache the diagram above describes are singletonsCurrentlyInCreation, singletonFactories, earlySingletonObjects and singletonObjects.
  • Spring Boot 2.6 release notes, on rejecting circular references by default.
Progress is saved on this device and to your account when signed in.