A Spring bean does not blink into existence fully formed. The container assembles it in a fixed order of stages — build the object, fill in its dependencies, run its setup, and much later, run its teardown. That ordered sequence is the bean lifecycle.
You meet it the first time you need something to happen at an exact moment. You want a connection pool opened after the bean has all its dependencies, but before it handles a single request. You want a cache flushed to disk when the app shuts down — but only if startup actually finished. To put your code in the right place, you have to know the order these stages run in, and what is guaranteed to be ready when each one fires.
The whole lifecycle is just the container doing work around an object it built. We will walk it end to end: the four main stages, the hooks Spring opens at each one, and two traps that fall straight out of the ordering.
The four stages, in one breath
Here is the spine of the whole article — the path every singleton bean walks, from startup to shutdown:
- Instantiate — construct the raw object.
- Populate — inject its dependencies.
- Initialize — run setup now that it is fully wired.
- Destroy — run teardown at shutdown.
Everything else is detail hung on those four pegs. Let's take them one at a time, because the order is the entire point — each stage is only safe to use once you know what ran before it.
Stage 1: instantiate — a bare object
First the container calls a constructor and gets back a raw Java object. Nothing Spring-specific has happened yet; this is the same new you would write by hand, just done for you.
@Component
class InventoryService {
private final WarehouseClient client;
InventoryService(WarehouseClient client) { // constructor injection
this.client = client;
}
}
There is a subtlety worth pausing on. When you use constructor injection — dependencies passed as constructor arguments, as above — the wiring happens inside this first stage. Spring works out what WarehouseClient is, builds it, and hands it in as the object is created. For constructor-injected beans, "instantiate" and "populate" are the same moment.
That is not true for the other injection styles, and the difference is where the next stage earns its name.
Stage 2: populate — the fields get filled
If a dependency is injected into a field or through a setter, it cannot arrive in the constructor — the object has to exist first, then Spring reaches in and sets it. That second step is populate: the container assigns every field- and setter-injected dependency onto the freshly built object.
@Component
class InventoryService {
@Autowired private WarehouseClient client; // set during populate, not in the constructor
}
This ordering explains a bug everyone hits once. A field-injected dependency is null if you touch it inside the constructor:
@Component
class InventoryService {
@Autowired private WarehouseClient client;
InventoryService() {
client.connect(); // NullPointerException — populate hasn't run yet
}
}
The constructor runs in stage 1. Populate is stage 2. At the moment the constructor body executes, the field is still empty. This is one more reason the previous article pushed constructor injection so hard: it collapses these two stages into one, so a dependency is never half-there. But when you do use field or setter injection, populate is the stage that fills them — and nothing before it can rely on them.
Between populate and the next stage, Spring may also hand the bean a few references to the container itself, if the bean asks for them by implementing an Aware interface (for example BeanNameAware to learn its own bean name). It is a narrow feature you will rarely reach for, but it slots in right here: after the dependencies, before initialization.
Stage 3: initialize — the bean gets ready
Now the object is fully wired. This is the moment to do setup that needs the dependencies in place — open that connection pool, warm a cache, validate configuration. Spring calls this the initialization stage, and it gives you a hook that fires exactly here:
@Component
class InventoryService {
private final WarehouseClient client;
private Connection pool;
InventoryService(WarehouseClient client) {
this.client = client;
}
@PostConstruct
void openPool() {
this.pool = client.connect(); // every dependency is guaranteed wired by now
}
}
The @PostConstruct method runs after populate, so it can lean on every dependency being present — the guarantee the constructor could not give you. This is the right home for "do this once, at startup, after wiring."
There are three ways to register init code, and when more than one is present they run in a fixed order:
- A method annotated
@PostConstruct— runs first. This is the standard, framework-agnostic choice. -
afterPropertiesSet(), if the bean implements theInitializingBeaninterface — runs second. It couples your class to Spring's API, so it is rarely the better option. - A method named in
@Bean(initMethod = "...")— runs last. Useful when the class is third-party and you cannot annotate it.
Reach for @PostConstruct unless you have a specific reason not to. The other two exist mostly for cases where you cannot put an annotation on the method.
The proxy slips in right after init
Remember from the first article that the container can wrap a bean in a proxy — a same-shaped stand-in that runs extra code (a transaction, an async hand-off) around your methods. The lifecycle is where that wrapping actually happens, and exactly when it happens is the source of a classic trap.
Spring exposes the init stage through an interface called BeanPostProcessor, which has two callbacks: one that runs before your init code, and one that runs after. The proxy is created in that second, after-init callback. So the order is:
- Populate the bean.
- Run your
@PostConstruct/ init methods — on the raw object. - Then wrap the result in a proxy and store that proxy in the container.
Read step 2 again, because it bites. Inside @PostConstruct, the proxy does not exist yet. The this you are holding is the bare bean. So if your init method calls one of its own proxied methods, the wrapper is skipped:
@PostConstruct
void warmUp() {
this.reload(); // if reload() is @Transactional, there is NO transaction here
}
reload() may be annotated @Transactional, but calling it through this during init goes straight to the raw method — the proxy that would have opened the transaction has not been built yet. The fix is not to depend on proxied behavior from inside initialization. The lesson generalizes: your own init code always sees the unwrapped bean. Everyone else, fetching the bean from the container later, sees the proxy.
In service
Once initialization and any wrapping are done, the bean is finished. The container drops it on the shelf and hands out that same instance — the proxy, if there is one — to everyone who needs it, for the rest of the application's life. No more lifecycle events fire until shutdown. For a normal singleton, that "in service" period is essentially the whole life of the program.
Stage 4: destroy — teardown at shutdown
When the application context closes — a graceful shutdown, a SIGTERM, the JVM shutdown hook firing — Spring walks its singletons one last time and runs their teardown. This is the place to release what initialization acquired: close the pool, flush the buffer, deregister from a discovery service.
@PreDestroy
void closePool() {
pool.close(); // mirror of @PostConstruct, run on the way down
}
Destruction mirrors initialization exactly, including the three-way ordering:
- A method annotated
@PreDestroy— runs first. -
destroy(), if the bean implementsDisposableBean— runs second. - A method named in
@Bean(destroyMethod = "...")— runs last.
One honest caveat: these run only on an orderly shutdown, when Spring is given the chance to close the context. A hard kill — kill -9, a power loss — bypasses the whole stage. So @PreDestroy is the right place for a graceful flush, but it is not a guarantee that your teardown will ever run. Anything that absolutely must survive a crash needs to be durable by other means, not parked in a destroy callback.
The prototype trap
Everything above describes a singleton — the default, one shared instance the container holds and manages. Spring also offers the prototype scope, where you get a brand-new bean every time you ask for one. Prototypes go through the first three stages in full: Spring instantiates them, populates them, and runs their init callbacks.
Then it lets go. Spring does not run the destroy stage for prototype beans. It builds one, hands it to you, and forgets it ever existed — so @PreDestroy on a prototype is never called by the container.
That is a real leak waiting to happen. If a prototype opens a file handle or a socket in @PostConstruct, expecting @PreDestroy to close it, the close never fires and the resource leaks every time you request the bean. For a prototype that holds something which must be released, you are responsible for closing it yourself — the lifecycle only takes you halfway.
Putting it together
A bean is built in four ordered stages, and the order is the whole point. Instantiate makes the raw object — and for constructor injection, wires it at the same time. Populate fills in any field- and setter-injected dependencies, which is why those are null if you touch them too early. Initialize runs your setup once everything is wired, with @PostConstruct as the natural home. And destroy runs your teardown on an orderly shutdown, with @PreDestroy as its mirror.
Two traps fall straight out of that ordering. The proxy is built after your init code, so initialization always sees the unwrapped bean — self-calls to transactional or async methods quietly do nothing there. And prototypes get the first three stages but never the fourth, so anything they open, you must close.
Hold on to the shape rather than the annotations: wired, then ready, then — much later, and only if you shut down cleanly — gone.
Top comments (0)