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.
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
ApplicationContextis aBeanFactoryplus 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.postProcessAfterInitializationis 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
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.
@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
| Scope | Instances | Lifecycle managed | Notes |
|---|---|---|---|
singleton | One per container | Fully | The default |
prototype | New on every lookup | Creation only | No destroy callback |
request | One per HTTP request | Fully | Web contexts only |
session | One per HTTP session | Fully | Watch memory footprint |
application | One per ServletContext | Fully | Wider than singleton in some setups |
websocket | One per WebSocket session | Fully | Rarely used directly |
Injecting a shorter-lived bean into a longer-lived one needs a proxy, otherwise the singleton captures the first instance forever:
@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.
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:
@Primaryon one definition makes it the default.@Qualifier("name")at the injection point picks explicitly.@Profileremoves candidates entirely per environment.- Injecting
List<T>orMap<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.
@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?
Does Spring manage the full lifecycle of prototype beans?
Is field injection really that bad?
Related tutorials
- Spring Boot Auto-Configuration Deep DiveHow Spring Boot auto-configuration actually works: the import selector, the @Conditional family, ordering rules, the --debug report, and how to write your own starter.
- Configuration & Profiles MasteryType-safe configuration with @ConfigurationProperties, the full property precedence order, relaxed binding rules, profile groups, and keeping secrets out of your YAML.
- Spring AOP & Aspect-Oriented ProgrammingSpring AOP from pointcut syntax to proxy mechanics: the five advice types, writing annotation-driven aspects, aspect ordering, and why self-invocation silently does nothing.
- Actuator & Observability EndpointsEvery Actuator endpoint worth exposing, writing custom health indicators for Kubernetes probes, adding Micrometer metrics that answer real questions, and securing it all.