A bean in Spring doesn't appear fully formed in a single instant. First Spring calls the constructor, then it injects dependencies, then it runs the initialization methods — and only after that is the bean ready to work. On application shutdown everything happens in reverse order. Understanding these phases removes a whole class of "magical" bugs: why everything is null in the constructor, why @PostConstruct runs too early, why resources aren't released on shutdown.
A bean is prepared step by step, and each step adds something the previous one didn't have: first the constructor parameters, then the fields, then the proxy. Cleanup comes last, and only on a graceful shutdown.
Why you even need to know about the phases
It seems like an object is just new MyService(...): you call the constructor and it's ready. That's not how Spring works — the bean travels down a conveyor line. First the body (constructor), then the engine and the wiring (dependency injection), then oil and ignition (initialization), and only at the end does the car roll off the line.
Hit the gas before the engine is connected and you get a NullPointerException or strange behavior. That's why it matters to know what is already available at each step.
The main phases in plain terms
From creation to shutdown a bean goes through five steps, drawn above: creation (the constructor), dependency injection, initialization, working and — when the application stops — destruction.
The key idea: a bean becomes ready gradually. In the constructor only what's passed into its parameters is available. Fields marked @Autowired are filled in after the constructor. A fully assembled bean appears only after the initialization phase.
In the constructor, field dependencies are still null
A common mistake. The code looks logical, but it fails on startup with a NullPointerException:
@Service
public class OrderService {
@Autowired
private MetricsClient metrics; // injected into the field
public OrderService() {
metrics.count("started"); // ← NPE! metrics is still null
}
}
Why: Spring first calls the constructor and only then fills the @Autowired fields — while the constructor runs, metrics hasn't been set yet.
The fix is to inject through the constructor, then the dependency is available right away:
@Service
public class OrderService {
private final MetricsClient metrics;
public OrderService(MetricsClient metrics) {
this.metrics = metrics; // metrics is already here
metrics.count("started"); // works
}
}
With constructor injection there's simply no difference between the "creation phase" and the "injection phase" for your code: by the time the body runs, all dependencies are in place. This is one of the reasons constructor injection is preferred over field injection.
The order is visible without Spring too: here three lines replace the container.
live example
import java.lang.reflect.Field;
public class LifecyclePhases {
static class MetricsClient {
void count(String name) {
System.out.println(" metric " + name + " sent");
}
}
static class OrderService {
private MetricsClient metrics; // the "@Autowired field"
OrderService() {
System.out.println("1. constructor: metrics = " + metrics);
}
void init() { // the "@PostConstruct method"
System.out.println("3. initialization: metrics = " + (metrics == null ? "null" : "injected"));
metrics.count("started");
}
}
public static void main(String[] args) throws Exception {
OrderService bean = new OrderService();
Field field = OrderService.class.getDeclaredField("metrics");
field.setAccessible(true);
field.set(bean, new MetricsClient());
System.out.println("2. injection: the field is filled");
bean.init();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The first line of output says metrics = null: the field was filled only on the second step. Spring does the same, finding the field by @Autowired and the method by @PostConstruct.
@PostConstruct — code that runs "after assembly"
Often you need to do something once, when the bean is already fully assembled. You can't put this in the constructor — not everything is available there yet (if you inject into fields). That's what the initialization phase is for.
You mark a method with the @PostConstruct annotation — and Spring will call it after all dependencies have been injected:
@Component
public class CategoryCache {
private final CategoryRepository repo;
private final Map<UUID, Category> cache = new ConcurrentHashMap<>();
public CategoryCache(CategoryRepository repo) {
this.repo = repo;
}
@PostConstruct
public void warmup() {
repo.findAll().forEach(c -> cache.put(c.id(), c)); // repo is already available
}
}
This is the modern and recommended approach. The @PostConstruct annotation is part of the Java standard (the jakarta.annotation package); it doesn't tie your class to Spring.
What people usually do in @PostConstruct:
- warming up a cache — load reference data into memory ahead of time;
- registration — add yourself to a registry of handlers, subscribe to events;
- checking settings — "if an important parameter isn't set, fail immediately with a clear error rather than an hour later in production."
Three ways to initialize and the order they run in
Besides @PostConstruct there are two more ways to say "initialize the bean." All three do the same thing but are used in different situations. If several are configured on a single bean, Spring calls them strictly in this order:
1. @PostConstruct — an annotation on a method. The idiomatic choice for your own code.
@PostConstruct
public void init() {
// all dependencies are injected, the bean is ready
}
2. InitializingBean.afterPropertiesSet() — you implement a Spring interface.
@Component
public class MyService implements InitializingBean {
@Override
public void afterPropertiesSet() {
// the same as @PostConstruct
}
}
It works, but it ties the class to the Spring framework. Not recommended in new code — @PostConstruct is cleaner.
3. Custom initMethod — you specify the method name in @Bean.
@Bean(initMethod = "customInit")
public ThirdPartyService service() {
return new ThirdPartyService();
}
Useful when the class isn't yours (from a third-party library) and you can't put an annotation on it.
Bottom line: in your own code — @PostConstruct. For third-party classes — initMethod. Leave InitializingBean for older code.
What you should NOT do during initialization
Heavy work in @PostConstruct blocks application startup. Spring won't let traffic in until all initialization methods have finished. If inside @PostConstruct you make a long request or load gigabytes of data, startup will stretch to minutes. In Kubernetes this is dangerous: the orchestrator will decide the pod is stuck and restart it — over and over.
If the work really is long, move it out of the initialization phase into the ApplicationReadyEvent event — it fires only after a full startup and doesn't delay traffic:
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
CompletableFuture.runAsync(this::heavyWarmup); // in the background, startup isn't blocked
}
Another trap: at the @PostConstruct stage Spring hasn't yet wrapped the bean in a proxy. This means annotations like @Transactional or @Async on methods won't work if you call them from @PostConstruct. If you need such logic at startup — again, use ApplicationReadyEvent; by that point the proxy already exists. With one caveat: a this.doWork() call inside the same bean goes past the proxy — you need @Transactional on the listener method itself, or a call into a neighbouring bean.
A proxy is an ordinary wrapper object, and that is visible in plain Java too.
live example
import java.lang.reflect.Proxy;
public class ProxyAfterInit {
interface Reports {
void build();
}
static class ReportService implements Reports {
public void build() {
System.out.println(" building the report");
}
void warmup() { // the "@PostConstruct method"
System.out.println("@PostConstruct — no proxy yet:");
build();
}
}
public static void main(String[] args) {
ReportService raw = new ReportService();
raw.warmup();
Reports bean = (Reports) Proxy.newProxyInstance(
Reports.class.getClassLoader(),
new Class<?>[]{Reports.class},
(proxy, method, args2) -> {
System.out.println(" transaction opened");
Object result = method.invoke(raw, args2);
System.out.println(" committed");
return result;
});
System.out.println("context is ready — the call goes through the proxy:");
bean.build();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The first call printed only "building the report" — there was no wrapper yet. The second went through the proxy, and "transaction opened" and "committed" appeared around the very same method.
@PreDestroy — cleanup on shutdown
When an application shuts down, beans don't just vanish. If a bean holds resources — a thread pool, an open connection, a file — they must be closed properly. Otherwise threads hang and connections leak.
For this there's a phase mirroring initialization — destruction. You mark a method with @PreDestroy, and Spring will call it before the bean is stopped:
@Component
public class TaskExecutor {
private final ExecutorService pool = Executors.newFixedThreadPool(4);
@PreDestroy
public void shutdown() {
pool.shutdown(); // stop accepting new tasks
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // didn't finish — interrupt them
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
pool.shutdownNow();
}
}
}
awaitTermination declares InterruptedException — a bare call won't compile, the wait has to sit in a try.
As with initialization, there are three ways, in the same order: @PreDestroy (recommended) → DisposableBean.destroy() (the old way) → custom destroyMethod in @Bean. For beans from @Bean methods Spring calls close() or shutdown() itself on shutdown; for @Component classes there is no such inference — there you need @PreDestroy.
An important detail: @PreDestroy is called only on a graceful shutdown (the SIGTERM signal, ctx.close()). If the process is killed hard with kill -9 (SIGKILL), the JVM dies instantly and Spring has no time to clean anything up. You can't rely on it.
Prototype beans are not destroyed
Spring manages the full lifecycle only for singleton beans (one instance for the whole application — this is the default behavior).
For beans with the prototype scope (a new instance on every request), Spring creates the object, hands it over, and immediately "forgets" it. The @PreDestroy method of such a bean is never called. From there its fate is handled by Java's ordinary garbage collector.
Conclusion: if a prototype bean holds resources, releasing them is the job of whatever code requested it — Spring is no help here.
Graceful shutdown — don't cut off requests
In production it's not enough to just close the beans — it's important not to cut off requests that are being processed right now. If a user is placing an order when the signal arrives, a rough shutdown interrupts the operation halfway through.
Spring knows how to shut down carefully. It's enabled with two lines:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
After a shutdown signal Spring:
- stops accepting new requests;
- waits for the current ones to finish (up to the specified timeout);
- calls
@PreDestroyon all beans; - stops the application.
Without this setting Spring starts tearing down beans immediately, cutting off requests midway.
In short
- A bean is assembled step by step: creation (constructor) → dependency injection → initialization → working → destruction.
- In the constructor only the parameters are available;
@Autowiredfields are filled in after it — that's why dependencies go through the constructor. - Code that runs "after assembly" goes into
@PostConstruct; there are three ways to initialize, in call order:@PostConstruct→InitializingBean.afterPropertiesSet()→ custominitMethod. - Heavy work in
@PostConstructblocks startup — move it intoApplicationReadyEvent; that's also where@Transactional/@Asyncwork, since at the@PostConstructstage there's no proxy yet. - Cleanup on shutdown goes into
@PreDestroy(equivalents:DisposableBean, customdestroyMethod); for prototype beans it is never called at all. graceful shutdownlets you wait for current requests before stopping;kill -9leaves no chance for cleanup.
What to read next
- DI/IoC and scopes — how Spring creates and wires beans in the first place.
- Auto-configuration, properties, profiles — how Spring Boot assembles the context automatically.
- Spring AOP — how
@Transactionaland the like turn a class into a proxy. - Spring Events — about
ApplicationReadyEventand the other context events.