Beans, lifecycle and scopes
Definition, instantiation, population, initialisation, destruction — and singleton, prototype, request scopes with their proxies.
A bean is not "an object Spring made". It is an object that passed through a defined sequence of phases, each of which is a place other code can intervene — and most of the surprising behaviour in a Spring application is something intervening at a phase you did not know existed. This lesson walks that sequence, then the scopes that decide how many times it runs and who gets the result.
A definition is not an instance
Before anything is constructed, the container builds an index of BeanDefinitions: class, scope, constructor arguments, init and destroy method names, lazy or not. They come from component scanning, from @Bean methods, and from anything that registers one programmatically.
This two-phase design is the reason several things work at all. Definitions can be modified before any object exists — that is what a BeanFactoryPostProcessor does, and how @Value("${…}") placeholders are resolved against properties before a constructor ever sees them. It is also why a bean whose class is missing from the classpath fails with a clear message about a definition rather than a NoClassDefFoundError halfway through startup.
BeanFactory is the container interface that holds definitions and produces beans. ApplicationContext extends it with the things an application needs — events, message sources, resource loading, and eager instantiation of singletons at startup.
The sequence
construct. The constructor runs, with dependencies resolved and passed in. Field and setter dependencies are not set yet.
populate. Setter and field injection happen now, by reflection. This is the window a constructor cannot see.
aware. BeanNameAware, BeanFactoryAware, ApplicationContextAware — the bean is handed pieces of its own container.
BPP before. postProcessBeforeInitialization on every BeanPostProcessor. Each may return a different object than it was given.
initialise. @PostConstruct, then InitializingBean.afterPropertiesSet, then the init-method. All dependencies are present.
BPP after. postProcessAfterInitialization. This is where @Transactional and @Async wrap the bean in a proxy — so the object the container keeps is not the one the constructor built.
ready. The finished object — usually the proxy — goes into the singleton registry and is handed to everyone who asked for the type.
destroy. On context close: @PreDestroy, DisposableBean.destroy, then the destroy-method. Singletons only.
Two phases in that sequence explain most of what people find surprising.
Populate comes after instantiate, which is the half-built window from the previous lesson: an @Autowired field read inside a constructor is null, because nothing has written it yet.
BPP after can replace the object. postProcessAfterInitialization returns a bean, and nothing says it must be the one it received. @Transactional, @Async, @Cacheable and Spring Security's method annotations all work by returning a proxy here. The consequence is worth stating plainly: the object in the registry is usually not the object your constructor built, and that is the root of the self-invocation problem the AOP lesson covers.
@PostConstruct and @PreDestroy
These are the callbacks to use. They are plain annotations — jakarta.annotation — so the class does not implement a Spring interface and can be tested without one.
@Component
class ConnectionWarmer {
private final DataSource dataSource;
ConnectionWarmer(DataSource dataSource) { this.dataSource = dataSource; }
@PostConstruct
void warm() throws SQLException {
try (var c = dataSource.getConnection()) { c.isValid(2); }
}
@PreDestroy
void drain() { /* stop accepting, let in-flight finish */ }
}The older equivalents — InitializingBean.afterPropertiesSet and DisposableBean.destroy — run in the order shown in the diagram and couple the class to Spring. @Bean(initMethod = …, destroyMethod = …) is the third form, and the right one for a class you do not own.
Scopes
| Scope | One instance per | Destroyed by the container |
|---|---|---|
singleton (default) | container | yes |
prototype | injection or lookup | no |
request | HTTP request | yes, at request end |
session | HTTP session | yes, at session end |
application | ServletContext | yes |
The prototype row is the one that catches people. The container creates a prototype, wires it, initialises it — and then forgets it. There is no registry entry, so no destroy callback ever runs. A prototype holding something that must be closed is a leak the container will not help with; the class needs to be closed by whoever asked for it.
Scoped proxies, and the problem they solve
A singleton that injects a request-scoped bean is a contradiction: the singleton is created once, at startup, when no request exists.
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
class RequestContext {
private String traceId;
// …
}The proxy mode changes what is injected. The singleton gets a proxy — one object, created at startup, that on every method call looks up the real request-scoped instance for the current thread and delegates to it. Without proxyMode, startup fails, because there is no request to create the bean from.
The same mechanism is what ObjectProvider<RequestContext> gives you more explicitly: ask for the instance when you need it rather than holding a reference to one.
Under the hood: what a BeanPostProcessor actually sees
A BeanPostProcessor is a bean itself, which creates an ordering problem the container has to solve: post-processors must exist before the beans they process. So the container instantiates them first, ahead of ordinary singletons, and a bean that a post-processor depends on is therefore created early — before the post-processors are all registered.
That produces one of the more confusing startup warnings in Spring:
Bean 'myService' of type [MyService] is not eligible for getting processed
by all BeanPostProcessors (for example: not eligible for auto-proxying)It means exactly what it says. Something pulled myService into existence early — usually a @Bean method in a configuration class that also defines a BeanPostProcessor, or a @Bean returning a post-processor without declaring it static. Because the bean was built before auto-proxying was in place, it is not proxied: its @Transactional methods are not transactional, and nothing fails. The fix is to make the post-processor-returning @Bean method static, so the configuration class need not be instantiated to call it.
@Order and the Ordered interface control the sequence among post-processors, which matters when two of them both want to wrap the same bean.
Walkthrough: the cache that was empty in production and full in tests
A team added @Cacheable to a pricing lookup. Integration tests showed the cache working. In production the hit rate was zero.
The bean was created by a @Bean method in a @Configuration class that also declared a BeanFactoryPostProcessor as a non-static @Bean. That forced the configuration class — and everything it produced — to be instantiated before the caching infrastructure's post-processor was registered. The pricing bean was created and never wrapped, so @Cacheable did nothing. In tests a different slice configuration created the bean the ordinary way, and it was proxied.
The startup log had said so, once, at INFO, in the line above.
Try it yourself
In what order?
@Component
class A implements InitializingBean {
@Autowired private B b;
A() { System.out.println("ctor " + b); }
@PostConstruct void post() { System.out.println("post"); }
@Override public void afterPropertiesSet() { System.out.println("aps"); }
}Answer
ctor null, then post, then aps. The field is injected in the populate phase, which is after the constructor — so the constructor prints null. @PostConstruct runs before afterPropertiesSet, which is the documented order and the opposite of what most people guess. Take B as a constructor parameter and the first line becomes impossible to get wrong.
Why does close() never run?
@Bean @Scope("prototype")
ReportWriter reportWriter() { return new ReportWriter(); } // implements AutoCloseableAnswer
The container does not keep prototypes, so it has no list to destroy and never calls close(). Each getBean produces an instance the caller owns. Either manage it yourself in a try-with-resources, or use ObjectProvider<ReportWriter> and close what you obtain. A destroyMethod on a prototype @Bean is silently ignored, which is worse than an error.
Which one is null?
A singleton injects a request-scoped bean without proxyMode. What happens, and when?
Answer
Not null — the application does not start. The container tries to create the request-scoped bean to inject it, finds no active request, and fails with ScopeNotActiveException wrapped in a bean creation failure. Adding proxyMode = ScopedProxyMode.TARGET_CLASS injects a proxy instead, which resolves the real instance per call; a call made off a request thread then fails at that call rather than at startup.
Misconceptions
- "
@PostConstructruns after the bean is fully ready." It runs beforepostProcessAfterInitialization, so the proxy does not exist yet. A@PostConstructmethod calling one of the bean's own@Transactionalmethods is not in a transaction. - "Prototype means a new instance every time I call the method." It means a new instance every time it is injected or looked up. A prototype injected once into a singleton is created once and then held forever.
- "Singleton beans are thread-safe because Spring manages them." One instance shared across every request thread is the definition of shared mutable state if the bean has fields.
- "
@Lazyis a performance optimisation." It moves the cost from startup to the first call and turns a startup failure into a runtime one. Occasionally worth it; not free. - "The bean I get is the class I wrote." Often it is a CGLIB subclass of it. This matters the moment you compare classes, read annotations reflectively, or expect a
finalmethod to be intercepted.
Going deeper
- Spring Framework reference, Core Technologies → Bean Overview, Customizing the Nature of a Bean, and Bean Scopes.
AbstractAutowireCapableBeanFactory#doCreateBeanand#initializeBeanin the framework source — the sequence above is those two methods, read top to bottom.PostProcessorRegistrationDelegate, for why the early-instantiation warning exists and what triggers it.