Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Spring IoC Container Internals

The Spring container from the inside: the full bean lifecycle in order, what BeanPostProcessor actually intercepts, every bean scope, and how circular dependencies are resolved.

Intermediate6 min readUpdated
On this page

Most Spring bugs that feel mysterious are lifecycle bugs: a value is null in the constructor, a proxy is not applied, a @PreDestroy never fires. All of them become obvious once you can picture the order in which the container does things. This guide walks that order step by step.

Key Takeaways

  • ApplicationContext is a BeanFactory plus events, resource loading, i18n and automatic post-processor registration.
  • The lifecycle has a fixed order: instantiate, populate, aware callbacks, before-init, initialise, after-init, use, destroy.
  • BeanPostProcessor.postProcessAfterInitialization is where AOP proxies are created — which is why a bean can be a proxy everywhere except inside its own constructor.
  • Spring resolves circular dependencies through an early-reference cache, and only for setter or field injection. Constructor cycles always fail.
  • Prototype beans get no destruction callback.

BeanFactory and ApplicationContext

BeanFactory is the minimal contract: get a bean by name or type, lazily. ApplicationContext extends it and adds everything you actually use — @EventListener publishing, MessageSource, Environment, resource loading, and automatic detection of BeanPostProcessor and BeanFactoryPostProcessor beans. It also eagerly instantiates singletons at startup, which is why configuration errors surface on boot instead of on first request.

That eager instantiation is a feature. A typo in a @Value expression fails the deployment rather than the 3am request that first touches the bean.

The lifecycle, in order

The complete Spring bean lifecycle. Proxies are applied at the last post-processing step, after initialisation.

Two consequences of this ordering are worth internalising.

Injected dependencies are not available in the constructor unless you inject them there. Field injection happens at step two, after the constructor has already run. A field annotated @Autowired is null inside the constructor body, every time.

A bean is not yet proxied during its own initialisation. @Transactional, @Cacheable and @Async all work by wrapping the bean in a proxy created at step eight. Calling an annotated method from inside @PostConstruct calls the raw target, and the annotation does nothing.

LifecycleDemo.java
@Component
public class LifecycleDemo implements InitializingBean, DisposableBean {
 
    private final PricingClient client;
    private List<Rule> rules;
 
    // 1. Constructor injection: dependencies are guaranteed non-null from here on.
    public LifecycleDemo(PricingClient client) {
        this.client = client;
    }
 
    @PostConstruct
    void loadRules() {
        // 5. Runs before afterPropertiesSet. The bean is NOT proxied yet, so
        // annotating this method @Transactional would have no effect.
        this.rules = client.fetchRules();
    }
 
    @Override
    public void afterPropertiesSet() {
        // 6. Prefer @PostConstruct: this couples the class to the Spring API.
        Assert.notEmpty(rules, "no pricing rules loaded");
    }
 
    @PreDestroy
    void flush() {
        // Called on graceful shutdown for singletons only.
    }
 
    @Override
    public void destroy() { }
}

Bean scopes

ScopeInstancesLifecycle managedNotes
singletonOne per containerFullyThe default
prototypeNew on every lookupCreation onlyNo destroy callback
requestOne per HTTP requestFullyWeb contexts only
sessionOne per HTTP sessionFullyWatch memory footprint
applicationOne per ServletContextFullyWider than singleton in some setups
websocketOne per WebSocket sessionFullyRarely used directly

Injecting a shorter-lived bean into a longer-lived one needs a proxy, otherwise the singleton captures the first instance forever:

ScopedProxy.java
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String correlationId;
    // getters and setters
}

With proxyMode set, the singleton receives a CGLIB proxy that resolves the real request-scoped instance on every method call. Without it, you get either a startup failure or — worse — one request's data leaking into every other request.

Circular dependencies

Spring keeps three maps of singletons: fully initialised ones, early references (constructed but not yet populated), and object factories. When bean A needs B and B needs A, the container can hand B a half-built reference to A from the early cache, finish B, and then finish A.

Setter injection breaks a cycle because the container can expose a partially built bean.

This only works when the cycle runs through setter or field injection. With constructor injection there is no point at which a partially built object exists, so the container fails fast with BeanCurrentlyInCreationException. Since Spring Boot 2.6, circular references are disabled by default and you must opt back in with spring.main.allow-circular-references=true.

Do not opt back in. A cycle is a design signal: two classes want to be one, or a third class wants to exist to hold the shared behaviour, or the dependency should be an event rather than a call. @Lazy on one constructor parameter is a legitimate escape hatch when you genuinely cannot refactor — it injects a proxy that resolves the target on first use.

Disambiguation

When several beans satisfy one type, the container needs a tiebreaker:

  • @Primary on one definition makes it the default.
  • @Qualifier("name") at the injection point picks explicitly.
  • @Profile removes candidates entirely per environment.
  • Injecting List<T> or Map<String, T> collects all of them, which is the cleanest solution when the answer is genuinely "all of them" — a strategy registry, a set of validators.
StrategyRegistry.java
@Service
public class PaymentRouter {
 
    private final Map<String, PaymentStrategy> strategies;
 
    // Spring injects every PaymentStrategy bean, keyed by bean name.
    public PaymentRouter(Map<String, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }
 
    public Receipt pay(String method, Order order) {
        var strategy = strategies.get(method + "PaymentStrategy");
        if (strategy == null) throw new UnsupportedPaymentMethodException(method);
        return strategy.charge(order);
    }
}

Lazy initialisation

spring.main.lazy-initialization=true defers every bean until first use. Startup gets dramatically faster, which is pleasant in development. In production it is usually the wrong trade: configuration errors move from boot time to request time, and the first request after a deploy pays the cost. Prefer @Lazy on the specific expensive beans — a client for a rarely used third-party API, a report generator — rather than globally.

What to take away

Almost every confusing Spring behaviour resolves to one of three facts: the constructor runs before injection, the proxy is applied after initialisation, and prototypes are never destroyed. Keep the lifecycle diagram in mind and the container stops being a black box.

Frequently Asked Questions

Why does @PostConstruct run before afterPropertiesSet?
@PostConstruct is invoked by CommonAnnotationBeanPostProcessor during postProcessBeforeInitialization, which by definition runs before the initialization callbacks. InitializingBean.afterPropertiesSet is one of those callbacks. The order is fixed: @PostConstruct, then afterPropertiesSet, then any custom init-method.
Does Spring manage the full lifecycle of prototype beans?
No. Spring creates, populates and initialises a prototype bean, then hands it over and forgets it. Destruction callbacks are never called, so anything holding a resource must be closed by the code that requested it — or wrapped in a custom BeanPostProcessor that registers it for cleanup.
Is field injection really that bad?
It hides required collaborators from the constructor, prevents final fields, and makes the class impossible to instantiate in a plain unit test without reflection. Constructor injection makes dependencies explicit and lets the compiler enforce them. The one thing field injection genuinely enables is unbreakable circular dependencies, which is not an argument in its favour.

Related tutorials