ML
Java / Spring

Spring Dependency Injection and the IoC Container

A senior engineer's tour of Spring's IoC container — bean definitions, why constructor injection wins, scopes, and how proxying actually works.

May 08, 20267 min readspringdependency-injection

Dependency injection is the boring part of Spring that everything else stands on. Get the mental model right — who constructs your objects, when, and behind what kind of proxy — and most of Spring's "magic" stops being magic.

1. Inversion of Control and the ApplicationContext

Inversion of Control (IoC) is the principle; dependency injection is the most common implementation of it. Instead of your code calling new on its collaborators, you declare what you need and a container hands them to you. Control over object construction and lifecycle is inverted — it moves from your code into the framework.

In Spring that container is the ApplicationContext. It is a registry of bean definitions (metadata: type, scope, dependencies, init/destroy hooks) plus a factory that turns those definitions into managed singletons. The interview-level answer: a bean definition is a recipe, a bean is the cooked instance. The context reads definitions from component scanning and @Configuration classes, then instantiates each bean by resolving its dependencies first — recursively creating a collaborator before the bean that needs it — so a bean's dependencies always exist by the time its constructor runs.

The container also gives you cross-cutting lifecycle for free: it calls @PostConstruct after injection, @PreDestroy on shutdown, and applies BeanPostProcessors that can wrap beans in proxies (for @Transactional, @Async, AOP). None of that works on an object you new yourself — Spring only manages what it instantiates.

2. Defining beans: @Component vs @Bean

There are two ways to register a bean. Stereotype annotations (@Component and its specializations @Service, @Repository, @Controller) mark your own classes for component scanning. The specializations are mostly semantic — but @Repository additionally enables persistence-exception translation (when a PersistenceExceptionTranslationPostProcessor is present, which Spring Boot auto-registers for JPA), and @Controller/@RestController are recognized by the MVC machinery. Use the specific one; it documents intent and occasionally changes behavior.

@Service
public class PricingService {
    private final TaxRepository taxRepository;

    public PricingService(TaxRepository taxRepository) {
        this.taxRepository = taxRepository;
    }
}

@Bean is for cases where you can't or shouldn't annotate the class: third-party types, or when construction needs logic. It lives on a method inside a @Configuration class, and the method's parameters are themselves injected.

@Configuration
public class HttpConfig {

    @Bean
    public RestClient restClient(ClientProperties props) {
        return RestClient.builder()
                .baseUrl(props.baseUrl())
                .requestInterceptor(new AuthInterceptor(props.token()))
                .build();
    }
}

Rule of thumb: @Component for your code, @Bean for everything you don't own or that needs assembly.

3. Constructor vs field vs setter injection

You can inject three ways. Field injection is the one you've seen most and should use least:

// Field injection — avoid
@Service
public class OrderService {
    @Autowired private PaymentGateway gateway;
    @Autowired private InventoryClient inventory;
}

Constructor injection is the one to default to:

// Constructor injection — preferred
@Service
public class OrderService {
    private final PaymentGateway gateway;
    private final InventoryClient inventory;

    public OrderService(PaymentGateway gateway, InventoryClient inventory) {
        this.gateway = gateway;
        this.inventory = inventory;
    }
}

Since Spring 4.3 the @Autowired on the constructor is optional when there's exactly one constructor, so the class above is plain Java with zero Spring annotations on its members — which is the point.

Why constructor injection wins, the answer an interviewer is listening for:

  • Immutability and safe publication. Dependencies are final. They're set once, can't be reassigned, and because the JMM guarantees that a thread observing a fully constructed object sees correctly initialized final fields, the bean is safe to share across threads — no half-built state.
  • No NPE / fully-formed invariant. You cannot construct an OrderService without its collaborators. With field injection, new OrderService() in a test compiles and then throws a NullPointerException the first time you touch gateway. Constructor injection makes the requirement structural.
  • Testability without Spring. A unit test just calls new OrderService(mockGateway, mockInventory). Field injection forces you to either start a context or use reflection (ReflectionTestUtils) to poke privates in.
  • Honest dependency count. A constructor with eight parameters is visibly painful, and that pain is a design signal that the class does too much. Field injection hides the count, so god-classes accumulate quietly.
  • Circular-dependency detection. The container can't satisfy two beans that each need the other in their constructor, so it fails fast at startup with a clear error. Field/setter injection can paper over a cycle by injecting late — see the next section.

Setter injection earns its keep narrowly: genuinely optional dependencies, or reconfigurable values. If a collaborator is required, it belongs in the constructor. Model true optionality with Optional<T> or @Nullable.

4. The circular-dependency pitfall

Consider two services that reference each other:

@Service
class A {
    private final B b;
    A(B b) { this.b = b; }   // needs B to construct
}

@Service
class B {
    private final A a;
    B(A a) { this.a = a; }   // needs A to construct
}

Spring must build one first, but each needs the other to already exist. Startup fails with an error like:

The dependencies of some of the beans in the application context
form a cycle:

┌─────┐
|  a defined in file [A.class]
↑     ↓
|  b defined in file [B.class]
└─────┘

This is a feature. The cycle is a real design smell — two classes that can't exist apart are really one responsibility split in two. The honest fix is to extract the shared logic into a third collaborator both depend on, or invert one edge with an event/callback.

People often "fix" it by switching to field injection or annotating one side @Lazy. With field injection Spring constructs both raw instances first and injects afterward, so the cycle resolves — at the cost of every reason from section 3. @Lazy injects a proxy that resolves the real bean on first use:

@Service
class A {
    private final B b;
    A(@Lazy B b) { this.b = b; }  // breaks the construction-time cycle
}

Note one asymmetry that trips people up: by default Spring resolves singleton cycles for field/setter injection but not constructor injection. And since Spring Boot 2.6, circular references are prohibited by default — spring.main.allow-circular-references is false, so even the field-injection escape hatch fails unless you explicitly flip it on. Treat all of these as a smell to design away, not a setting to enable.

5. Bean scopes

The default scope is singleton: one instance per container, pre-instantiated eagerly at startup (unless marked lazy), shared by every injection point. This is why Spring beans should be stateless — a singleton field mutated by concurrent requests is a data race. Keep request state on the stack (method params/locals), not on the bean.

prototype creates a new instance on every lookup. The sharp edge: Spring instantiates a prototype and then washes its hands — it does not invoke destruction callbacks, and a prototype injected once into a singleton is resolved once, so you don't get a fresh instance per call. For that you need a lookup mechanism:

@Service
public class ReportService {
    private final ObjectProvider<ReportBuilder> builders; // ReportBuilder is @Scope("prototype")

    public ReportService(ObjectProvider<ReportBuilder> builders) {
        this.builders = builders;
    }

    public Report build() {
        ReportBuilder builder = builders.getObject(); // fresh instance each call
        return builder.assemble();
    }
}

Web applications add request and session scopes — one instance per HTTP request or session. Injecting a request-scoped bean into a singleton needs a proxy (proxyMode = ScopedProxyMode.TARGET_CLASS) so the singleton holds a stable reference that dispatches to the correct per-request instance at call time. That proxy is the bridge between two lifetimes.

6. How Spring proxies beans: JDK vs CGLIB

Whenever a bean needs around-behavior — @Transactional, @Async, @Cacheable, scoped-proxy dispatch — Spring doesn't inject your object directly. A BeanPostProcessor wraps it in a proxy and injects the proxy. Calls go through the proxy's advice (open transaction, run, commit/rollback) and then to your method.

Spring picks one of two proxy strategies:

  • JDK dynamic proxies implement your bean's interfaces. The proxy is a separate class that delegates to the target. Consequence: the injected type is the interface, so you can't inject the concrete class, and only interface-declared methods are advised.
  • CGLIB proxies generate a runtime subclass of your concrete class and override its methods to insert advice. No interface required. This is the Spring Boot default (proxyTargetClass=true), which is why your final classes and final/private methods can't be proxied — you can't override what you can't subclass.

The proxy model explains the most common "why didn't my annotation fire" bug: self-invocation bypasses the proxy. An internal this.method() call doesn't go through the wrapper, so its @Transactional/@Cacheable is silently ignored:

@Service
public class InvoiceService {

    public void process(Invoice i) {
        save(i);          // self-call: NOT advised, no new transaction
    }

    @Transactional
    public void save(Invoice i) { /* ... */ }
}

The fix is to call through the proxy — move save to a separate bean, or inject a self-reference. The deeper point: advice lives on the proxy, and only calls that cross the proxy boundary see it. Once you internalize that, transaction and caching surprises mostly disappear.

Rules of thumb

  • Default to constructor injection with final fields; no @Autowired needed for a single constructor. Reserve setters for truly optional dependencies.
  • A constructor circular dependency is a design smell — extract a third collaborator or break the edge, don't paper over it with @Lazy or field injection.
  • Singletons are shared, so keep beans stateless; for per-call instances of a prototype, inject an ObjectProvider, not the bean.
  • Spring proxies via CGLIB by default (JDK dynamic proxies when interface-based) — which is why final methods and self-invocation silently skip @Transactional and friends.
  • Use @Component/@Service for code you own and @Bean in @Configuration for third-party types or assembly that needs logic.
SharePostLinkedIn

Reader Discussion

2 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Léa Dubois· SREAsks

    any chance you'd publish these as a PDF collection? would love to print and read offline on flights. screen-fatigue is real.

    May 14, 2026·6 days later
  2. Ahmed Rahman· Full StackKind words

    concise + opinionated = my favourite kind of engineering post. so many blogs hedge every claim into mush. give me the spicy take with the receipts. more please.

    May 09, 2026·1 day later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email