Senior 12 min · May 23, 2026

Spring Bean Lifecycle — What Actually Happens at Startup

Deep dive into Spring Bean Lifecycle: @PostConstruct, @PreDestroy, lazy init, scopes, and what really happens when your ApplicationContext starts.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Spring beans go through: instantiation → dependency injection → @PostConstruct → ready → @PreDestroy → destroy
  • @PostConstruct runs after all dependencies are injected, before the bean is put in service
  • @PreDestroy runs before the bean is destroyed — use it to close resources
  • Lazy init (@Lazy) defers bean creation until first use — helps startup time but hides wiring errors
  • Bean scope (singleton vs prototype) fundamentally changes how the container manages lifecycle
✦ Definition~90s read
What is Spring Bean Lifecycle?

The Spring bean lifecycle is the sequence of events from when the Spring IoC container decides to create a bean to when it destroys it. For singleton-scoped beans (the default), this lifecycle is tied to the ApplicationContext itself — the bean lives as long as the container lives.

Think of the Spring container as a factory floor.

For prototype-scoped beans, the container creates a new instance on every request and hands off lifecycle responsibility to the caller after creation.

The lifecycle phases in order are: (1) bean definition loading from @Component, @Bean, XML, or other sources; (2) instantiation via constructor; (3) dependency injection via setter or field injection; (4) awareness interface callbacks (BeanNameAware, ApplicationContextAware, etc.); (5) BeanPostProcessor.postProcessBeforeInitialization(); (6) @PostConstruct / InitializingBean.afterPropertiesSet(); (7) BeanPostProcessor.postProcessAfterInitialization() — this is where AOP proxies are created; (8) bean is ready and placed in the singleton cache; (9) on shutdown: @PreDestroy / DisposableBean.destroy().

Understanding that AOP proxies are created in phase 7 (postProcessAfterInitialization) is crucial. It means if you call a @Transactional method on the same bean inside @PostConstruct, you're calling it on the raw bean — the proxy doesn't exist yet — and the transaction won't start. This is a real production bug that developers discover too late.

Plain-English First

Think of the Spring container as a factory floor. When the factory opens (app starts), it builds all the machines (beans), connects their parts (injects dependencies), runs a startup check (@PostConstruct), and puts them to work. When the factory closes (app shuts down), it runs a shutdown procedure (@PreDestroy) before turning off each machine.

Every Spring developer has hit a NullPointerException inside a method annotated with @PostConstruct and wondered why their injected service is null. Or they've seen a database connection pool leak because cleanup code was placed in a constructor instead of @PreDestroy. These are bean lifecycle bugs — and they're almost always caused by not truly understanding the order in which Spring builds, wires, and destroys beans.

The Spring bean lifecycle is not just academic knowledge. It directly affects startup correctness, shutdown safety, and resource management in production. When you understand that @PostConstruct fires after injection but before the bean is exposed to other beans, you stop placing initialization logic in constructors. When you understand that @PreDestroy doesn't fire for prototype-scoped beans, you stop relying on it for cleanup in those cases.

In a microservices world where pods start and stop hundreds of times per day, lifecycle management is critical infrastructure. A bean that doesn't clean up its thread pool on shutdown causes thread leaks. A bean that tries to hit a database in its constructor — before the DataSource bean is ready — causes cascading startup failures that are a nightmare to debug in Kubernetes where readiness probes are also failing.

This article walks through every phase of the Spring bean lifecycle with real production examples, explains the gotchas that bite senior developers, and gives you the mental model you need to write beans that start cleanly and shut down safely. We'll cover @PostConstruct, @PreDestroy, BeanFactoryPostProcessor, BeanPostProcessor, lazy initialization, and scope-specific lifecycle behavior — all with working Spring Boot 3.x code.

Bean Instantiation and Dependency Injection Order

Spring's container starts by loading all bean definitions from your configuration sources — @ComponentScan packages, @Bean methods in @Configuration classes, auto-configuration classes from spring.factories/AutoConfiguration.imports. At this stage, no beans are created yet; the container just knows what it needs to build. This is the BeanDefinition phase, and you can hook into it with BeanFactoryPostProcessor to modify definitions before any instantiation.

Instantiation happens next. Spring calls the constructor of your class. If you're using constructor injection (which you should be), Spring resolves all constructor parameters first, potentially triggering instantiation of dependency beans. This is why circular dependencies fail at startup when you use constructor injection — Spring literally cannot construct A if A needs B and B needs A. With field injection, Spring uses reflection after construction, which is why circular dependencies could previously be resolved (Spring 6.x now throws by default for circular dependencies regardless).

After the constructor runs, Spring injects any remaining @Autowired fields or @Autowired setter methods. This is the dependency injection phase. After this phase completes, all @Autowired dependencies are guaranteed to be non-null — which is why @PostConstruct is safe to use them. This is in contrast to the constructor, where only constructor-injected dependencies are available.

The ordering of bean creation follows the dependency graph. If ServiceA depends on ServiceB, Spring ensures ServiceB is fully initialized (including its @PostConstruct) before injecting it into ServiceA. You can enforce explicit ordering beyond the dependency graph with @DependsOn('beanName'), though if you need @DependsOn often, it's a sign your dependency graph isn't expressed cleanly through injection.

One important nuance: Spring processes BeanPostProcessors before any regular beans. This means BeanPostProcessors themselves cannot have @Autowired dependencies on regular beans — if they do, those regular beans get instantiated early, before the full BeanPostProcessor chain is set up, potentially missing some processing. This is why you'll see warnings like 'Bean X is not eligible for getting processed by all BeanPostProcessors'.

@Transactional inside @PostConstruct doesn't work
AOP proxies — which enable @Transactional — are created after @PostConstruct runs. Calling a @Transactional method on 'this' inside @PostConstruct executes without a transaction. Use TransactionTemplate programmatically, or use an ApplicationReadyEvent listener instead.
Production Insight
Use constructor injection everywhere. It makes dependencies explicit, enables immutable fields, and causes circular dependencies to fail fast at startup — exactly where you want them to fail.
Key Takeaway
All @Autowired dependencies are available in @PostConstruct. AOP proxies (transactions, caching) are NOT — they come after.

BeanPostProcessor — The Power Hook You're Probably Not Using

BeanPostProcessor is the extension point that makes Spring AOP, @Transactional, @Async, @Cacheable, and dozens of other Spring features work. Every bean passes through the BeanPostProcessor chain twice: once before initialization (@PostConstruct) and once after. The 'after' pass is where Spring creates AOP proxies. If you return a different object from postProcessAfterInitialization(), that's what gets stored in the singleton cache and what other beans get injected with.

This has a critical implication: when a bean is proxied, the proxy is a subclass of your bean (for class-based proxies via CGLIB) or implements the same interfaces (for JDK dynamic proxies). The original raw bean is still there, held by the proxy. When you call context.getBean(MyService.class), you get the proxy, not the raw bean. This is why @Transactional doesn't work when you call a method on 'this' inside the same class — 'this' refers to the raw bean, bypassing the proxy.

Writing your own BeanPostProcessor is genuinely useful for cross-cutting concerns that don't fit existing annotations. Common use cases include: automatically registering beans that implement a marker interface into a registry, adding metrics instrumentation to services, validating configuration after all beans are wired. The pattern is clean — you don't touch the bean definition, you don't modify the code, you just intercept during the wiring phase.

A BeanFactoryPostProcessor, by contrast, runs even earlier — before any beans are instantiated. It operates on BeanDefinition objects. PropertySourcesPlaceholderConfigurer is a classic example: it replaces ${property} placeholders in bean definitions before Spring tries to create beans. You'd write a custom BeanFactoryPostProcessor if you needed to modify bean scopes, add constructor arguments, or change bean class based on environment conditions — all before instantiation.

One performance trap: every BeanPostProcessor is called for every bean. If you have 500 beans and 5 BeanPostProcessors, that's 5000 calls on startup. Keep BeanPostProcessor.postProcessBeforeInitialization() and postProcessAfterInitialization() fast. If you need to do expensive work, cache results keyed by bean class.

BeanPostProcessor vs @PostConstruct
BeanPostProcessor is for framework-level cross-cutting concerns — it processes all beans. @PostConstruct is for bean-specific initialization. Don't use BeanPostProcessor for per-bean setup; use it when you need to intercept all beans of a certain type or annotation.
Production Insight
When debugging AOP issues, log bean.getClass().getName() — if it contains '$$EnhancerBySpringCGLIB' or 'Proxy', you're looking at a proxy. The raw class is in bean.getClass().getSuperclass().
Key Takeaway
AOP proxies are created in postProcessAfterInitialization. Self-invocation bypasses the proxy — always inject the bean into itself or restructure if you need @Transactional on same-class calls.

Bean Scopes and Their Lifecycle Implications

Singleton scope (the default) means one instance per ApplicationContext. The container creates it, manages its lifecycle, calls @PostConstruct, stores it, and calls @PreDestroy on shutdown. Simple and predictable. 99% of your service-layer beans should be singletons.

Prototype scope means a new instance on every request. The container creates the bean and injects dependencies, but it does NOT call @PreDestroy and does NOT track the instance. This is a fundamental difference. If your prototype bean holds resources — connections, threads, file handles — YOU are responsible for closing them. Spring gives you the bean and walks away. Prototype beans are appropriate for stateful processing objects where each operation needs its own isolated state, like form wizards or batch processors.

Request scope (@RequestScope) creates one bean per HTTP request. Session scope (@SessionScope) creates one per HTTP session. Application scope (@ApplicationScope) is functionally equivalent to singleton but declared differently. These scopes require an active web context and use proxies to bridge scope boundaries — if a singleton bean injects a request-scoped bean, Spring injects a scoped proxy that delegates to the current request's instance at call time.

Scoped proxy injection is where developers get confused. If you inject a prototype bean into a singleton, you get the same prototype instance for the life of the singleton — defeating the purpose of prototype scope. The fix is to inject an ObjectProvider<T> or use @Lookup methods, which give you a fresh prototype instance on each call. Alternatively, inject a scoped proxy with @Scope(value = 'prototype', proxyMode = ScopedProxyMode.TARGET_CLASS).

In production microservices, request-scoped beans are useful for holding per-request context — the authenticated user, the correlation ID, the tenant ID — without passing them through every method call. But be careful: request-scoped beans don't propagate across thread boundaries. If you use @Async or spawn threads inside a request handler, the request scope is not available in the new thread. You need to explicitly copy the context using RequestContextHolder or a custom thread-local propagation mechanism.

Prototype beans: @PreDestroy is never called
If your prototype bean opens a database connection, starts a thread, or acquires any resource — YOU must close it. Spring will not call @PreDestroy. Implement DisposableBean or use try-with-resources. This is a common resource leak in applications that use prototype beans for connection-per-request patterns.
Production Insight
In a multitenant SaaS app, we stored tenantId in a @RequestScoped bean and injected it as a scoped proxy into our singleton DataSourceRouter. This cleanly routed database calls to the right tenant DB without any ThreadLocal management in business code.
Key Takeaway
Prototype beans: Spring creates, you destroy. Inject prototypes into singletons via ObjectProvider, never via direct @Autowired — or you'll get a single shared instance.

Lazy Initialization — Startup Speed vs Fail-Fast Safety

Lazy initialization (@Lazy or spring.main.lazy-initialization=true) delays bean creation until the bean is first requested. This sounds great for startup time, and it genuinely helps — a Spring Boot app with 400 beans might start 40% faster with lazy init because many of those beans are never touched in the first few seconds. Kubernetes readiness probes pass faster, deployments roll out quicker.

But lazy init has a dark side: it moves wiring errors from startup time to runtime. In eager (default) mode, if you have a @Service that @Autowired something that doesn't exist, you get a BeanCreationException on startup — before any traffic hits the app. The deployment fails, the rollout stops, you're paged. With lazy init, that same misconfiguration silently waits until the first request touches that bean, then explodes with a 500 error in production at 2 AM on a Tuesday.

The right approach in production is to enable global lazy init for startup speed but explicitly mark critical path beans as eager. You do this by annotating specific beans with @Lazy(false) — which overrides the global lazy setting and forces eager initialization. Your database connection pool, your cache warmup logic, your security configuration — these should all be eager so misconfigurations fail fast.

Another important nuance: @Lazy on an injection point creates a lazy proxy at the injection site. Even if the target bean is eager (singleton), injecting it with @Lazy delays proxy creation. This is useful for breaking circular dependencies — if A needs B and B needs A via constructor injection, you can annotate one constructor parameter with @Lazy to break the cycle. Spring creates a lazy proxy for that dependency, avoiding the chicken-and-egg problem.

For Spring Boot 3.x applications, the recommended production setup is: spring.main.lazy-initialization=true in application.properties, combined with @Lazy(false) on your DataSource, EntityManagerFactory wrapper beans, and any bean whose @PostConstruct does critical validation. Monitor startup time with the /actuator/startup endpoint to identify slow beans and decide which truly benefit from eager loading.

Use @Lazy to break circular dependencies as a last resort
If you have a genuine circular dependency that you can't resolve by restructuring, @Lazy on a constructor parameter breaks the cycle by injecting a proxy. But treat it as a code smell — circular dependencies usually indicate SRP violations. Refactor to extract the shared logic into a third service that both depend on.
Production Insight
We reduced startup time from 18s to 11s on a 380-bean Spring Boot app by enabling global lazy init and explicitly marking only 12 critical beans as @Lazy(false). Zero configuration errors moved to runtime — because our CI pipeline has an integration test that exercises every endpoint.
Key Takeaway
Global lazy init speeds startup but hides wiring errors. Compensate by marking critical beans @Lazy(false) and having integration tests that exercise all beans.

Graceful Shutdown and @PreDestroy in Production

Graceful shutdown is non-negotiable in production. When Kubernetes sends SIGTERM to your pod, you have a window (terminationGracePeriodSeconds, default 30 seconds) to finish in-flight requests and clean up resources. If your JVM exits before cleanup, you get connection leaks, uncommitted transactions, and half-processed messages in Kafka or RabbitMQ.

Spring Boot 2.3+ added built-in graceful shutdown support via server.shutdown=graceful in application.properties. When enabled, the embedded Tomcat/Netty stops accepting new connections on SIGTERM but allows in-flight requests to complete up to spring.lifecycle.timeout-per-shutdown-phase (default 30 seconds). Your @PreDestroy methods and DisposableBean.destroy() methods run after the web server stops accepting requests — guaranteeing that no new business logic starts while you're cleaning up.

The ordering of @PreDestroy calls matters. Spring destroys beans in reverse order of their initialization. If ServiceA was initialized after RepositoryB (because ServiceA depends on RepositoryB), then on shutdown: ServiceA's @PreDestroy runs first, then RepositoryB's. This ordering ensures that higher-level beans stop using lower-level beans before those lower-level beans close their resources. In practice, this means your thread pools should shut down before your database connections close.

For Kafka consumers, @PreDestroy is where you call consumer.wakeup() or stop the listener container. For thread pools (ExecutorService), you call shutdown() then awaitTermination() with a timeout, then shutdownNow() if it didn't finish. For HTTP client connection pools, you call close(). For caches with write-behind, you flush pending writes. These aren't optional — they're the difference between a clean deployment and data corruption.

One subtle gotcha: if your @PreDestroy throws an exception, Spring logs the error and continues destroying other beans. The exception is swallowed. This means a failed cleanup doesn't prevent other beans from being destroyed, which is the right behavior — but it also means you must explicitly handle exceptions in @PreDestroy and log them. Don't let exceptions propagate out of @PreDestroy silently.

Docker ENTRYPOINT shell form breaks SIGTERM
ENTRYPOINT ["sh", "-c", "java -jar app.jar"] means SIGTERM goes to sh, not Java. The JVM never receives it, Spring's shutdown hook never runs, @PreDestroy never fires. Always use exec form: ENTRYPOINT ["java", "-jar", "/app/app.jar"]. This is one of the most common production deployment bugs.
Production Insight
Set terminationGracePeriodSeconds in your Kubernetes deployment to 60 seconds and server.shutdown=graceful with spring.lifecycle.timeout-per-shutdown-phase=45s. The gap gives Kubernetes time to remove the pod from service endpoints before the JVM starts shutdown.
Key Takeaway
server.shutdown=graceful + @PreDestroy + ENTRYPOINT exec form = clean Kubernetes rolling deploys without dropped requests or resource leaks.

Advanced Lifecycle: SmartLifecycle and Phase Ordering

SmartLifecycle is the interface you implement when you need precise control over the startup and shutdown order of components that aren't in the bean dependency graph. Classic examples: Kafka consumer containers, scheduled job runners, health check publishers. These components don't inject each other, but they have an implicit ordering requirement — you want your database schema migration to finish before Kafka consumers start processing messages.

SmartLifecycle adds getPhase() to the basic Lifecycle start/stop contract. Beans with lower phase numbers start first and stop last. Spring Boot's built-in components use well-known phase numbers: the web server starts at Integer.MAX_VALUE - 1 (very late), so it's the last thing to start and first to stop. You can hook into this system by implementing SmartLifecycle with a phase number between default components.

For example, if you want your cache warmer to run after the database is ready but before the web server accepts requests, use a phase number of 0 (database-related beans typically use negative phases). If you want your metrics publisher to start after everything else, use Integer.MAX_VALUE - 2. The SmartLifecycle framework handles the orchestration.

The isAutoStartup() method controls whether start() is called automatically by the context. If you return false, you have to manually call start() — useful for components you want to start on demand. isRunning() is called during health checks and context refresh — implement it correctly by tracking your actual running state, not just assuming you're running after start() is called.

In real production systems, SmartLifecycle is how you implement the 'warm-up' pattern: your application starts fully (from Spring's perspective), but you delay opening the web server port until you've pre-loaded caches, warmed JIT-compiled code paths, and run connectivity checks. Combined with Kubernetes preStop hooks and readiness probes, this gives you zero-downtime deployments even for services with expensive warm-up requirements.

Use ApplicationReadyEvent for post-startup business logic
For one-time initialization that should happen after the entire context is ready (including all SmartLifecycle.start() calls), use @EventListener(ApplicationReadyEvent.class). This is cleaner than SmartLifecycle for simple use cases and doesn't require implementing an interface.
Production Insight
We used SmartLifecycle at phase 500 to delay marking our readiness probe as healthy until both cache warmup (phase 100) and Kafka consumer lag check (phase 200) were complete. This prevented Kubernetes from routing traffic to a pod that would serve stale cache hits for the first 30 seconds.
Key Takeaway
SmartLifecycle with phase ordering is the right tool for startup sequencing of non-dependency-injected components. Use it to control when Kafka consumers, schedulers, and cache warmers start relative to each other.

The InitialDestroy Contract — When Annotations Aren’t Enough

You’ve seen @PostConstruct and @PreDestroy. They’re clean, simple, and work 90% of the time. But the other 10% will burn you in production. When you need guaranteed lifecycle callbacks across all bean creation paths — including programmatic registrations or proxies — implementing InitializingBean and DisposableBean gives you a contract the container cannot ignore. Annotations can be skipped if the bean is created outside standard component scanning. These interfaces fire after all properties are set and before the bean is returned to the caller. They’re Javadoc-ugly but predictably reliable. The why: your database connection pool or message queue client must start and stop exactly once. Annotations are suggestions; interfaces are promises. Use them for infrastructure beans where failure means a pager at 3 AM.

DatabaseConnector.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// io.thecodeforge — java tutorial
package com.thecodeforge.service;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

import javax.sql.DataSource;

@Component
public class DatabaseConnector implements InitializingBean, DisposableBean {

    private final DataSource dataSource;

    public DatabaseConnector(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        // Guaranteed to run after all dependencies are injected.
        // Not skipped by proxies or AOP weaving.
        System.out.println("Connection pool initialized: " + dataSource.getClass().getName());
    }

    @Override
    public void destroy() throws Exception {
        // Runs during shutdown, even if @PreDestroy is broken by circular deps.
        System.out.println("Connection pool shut down gracefully.");
    }
}
Output
Connection pool initialized: com.zaxxer.hikari.HikariDataSource
Connection pool shut down gracefully.
Production Trap:
@PostDestroy on a CGLIB proxy? It won’t fire. InitializingBean.DisposableBean always does. I’ve seen this cause connection leaks in Kubernetes rolling restarts.
Key Takeaway
For mission-critical beans, prefer InitializingBean/DisposableBean over annotations — they survive proxying and programmatic registration.

Aware Interfaces — The Container Wants To Tell You Something

Your bean lives inside the Spring container, but does it know who its neighbors are? Aware interfaces are the secret handshake that lets a bean peek at the container internals. BeanNameAware tells you your own ID in the context. ApplicationContextAware gives you the full container reference. BeanFactoryAware gives you the raw factory. Why should you care? Because sometimes you need to dynamically fetch other beans without hard-coding @Autowired dependencies — think strategy patterns or lazy lookup without circular injection. The order matters: these callbacks fire after property injection but before @PostConstruct. That means you can use the container reference inside your init method to pre-warm caches or register listeners. Don’t abuse this — it couples you to Spring — but for framework-level code or generic utilities, it’s the only clean escape hatch.

ServiceRegistry.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// io.thecodeforge — java tutorial
package com.thecodeforge.service;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component
public class ServiceRegistry implements ApplicationContextAware, BeanNameAware {

    private Map<String, Object> services = new HashMap<>();
    private String myBeanName;

    @Override
    public void setBeanName(String name) {
        this.myBeanName = name;
        System.out.println("I am: " + name);
    }

    @Override
    public void setApplicationContext(ApplicationContext ctx) throws BeansException {
        // Grab all beans of a type at startup — no circular deps.
        services.putAll(ctx.getBeansOfType(PaymentProcessor.class));
        System.out.println("Registered " + services.size() + " payment processors.");
    }
}
Output
I am: serviceRegistry
Registered 3 payment processors.
Why It Works:
BeanNameAware fires first, then ApplicationContextAware. Use both to log your bean’s identity before touching the container.
Key Takeaway
Aware interfaces fire between property injection and @PostConstruct — use them for dynamic lookups without circular dependencies.
● Production incidentPOST-MORTEMseverity: high

Scheduler Fires Before Database is Ready

Symptom
Application started successfully according to Spring logs, but the first scheduled task (every 10 seconds) threw 'HikariPool-1 - Connection is not available, request timed out after 30000ms'. Logs showed the @PostConstruct of the SchedulerInitBean ran at T+0.8s but the DataSource health check showed not-ready until T+2.1s.
Assumption
The developer assumed that since Spring Boot auto-configures the DataSource bean before component scanning, the DataSource would always be available when any @PostConstruct runs.
Root cause
The SchedulerInitBean had @DependsOn on the DataSource but not on the JPA EntityManagerFactory, which is what actually blocked until the schema was validated. The @PostConstruct started a scheduled thread that tried to query via JPA before EntityManagerFactory finished its schema validation DDL run.
Fix
Added @DependsOn('entityManagerFactory') to SchedulerInitBean. Moved the initial data load out of @PostConstruct into an ApplicationReadyEvent listener, which fires only after the entire context is ready: @EventListener(ApplicationReadyEvent.class). This guaranteed all infrastructure beans were fully initialized before any business logic ran.
Key lesson
  • Use ApplicationReadyEvent for business logic that needs the full application context ready.
  • Reserve @PostConstruct for bean-local initialization that only depends on the bean's own injected dependencies.
Production debug guideSymptom → root cause → fix for the most common bean lifecycle failures5 entries
Symptom · 01
NullPointerException in @PostConstruct method
Fix
Check whether the null field is @Autowired via field injection. If so, verify the bean exists in the context. Run with --debug flag to see bean wiring. More likely: the field is not annotated with @Autowired at all, or it's in a class that's not a Spring bean (e.g., instantiated with 'new'). Check that the class has @Component/@Service/@Repository or is returned from a @Bean method.
Symptom · 02
@PreDestroy not called on application shutdown
Fix
Check bean scope first — @PreDestroy is never called for prototype-scoped beans. For singletons, check if the JVM is being killed with SIGKILL (kill -9) instead of SIGTERM. Spring's shutdown hook only runs on SIGTERM/normal JVM exit. In Docker, ensure your CMD uses exec form (CMD ["java", ...]) not shell form (CMD java ...) — shell form means SIGTERM goes to the shell, not the JVM. Register a shutdown hook with context.registerShutdownHook() in non-Spring-Boot apps.
Symptom · 03
@Transactional not working inside @PostConstruct
Fix
This is expected behavior. AOP proxies that enable @Transactional are created in BeanPostProcessor.postProcessAfterInitialization(), which runs after @PostConstruct. So inside @PostConstruct, you're calling a method on the raw bean, not the proxy. Fix: inject a TransactionTemplate and use it programmatically inside @PostConstruct, or move the transactional work to an ApplicationReadyEvent listener.
Symptom · 04
Slow application startup, beans taking too long to initialize
Fix
Enable Spring Boot startup actuator: add management.endpoints.web.exposure.include=startup and hit /actuator/startup after boot. It shows each bean's instantiation time. Look for beans with init time over 500ms. Common culprits: @PostConstruct methods that make HTTP calls to external services (add @Lazy or move to async init), Flyway migrations running on startup (expected, but can be optimized), and EntityManagerFactory schema validation (set spring.jpa.hibernate.ddl-auto=validate only in prod where schema is pre-applied).
Symptom · 05
Bean created multiple times (seen in logs as duplicate initialization messages)
Fix
Check if the class is annotated with @Component AND also returned from a @Bean method — this creates two instances. Check if @ComponentScan is picking up the same package from multiple @Configuration classes. Use context.getBeanDefinitionNames() in a CommandLineRunner to list all registered beans and spot duplicates. Also check if you're using @Scope('prototype') — prototype beans are created fresh on every injection point.
★ Bean Lifecycle Debug Cheat SheetCommands and checks for fast diagnosis of bean lifecycle issues
Need to see all beans and their types
Immediate action
Hit the beans actuator endpoint
Commands
curl -s http://localhost:8080/actuator/beans | jq '.contexts.application.beans | keys[]' | grep -i mybean
curl -s http://localhost:8080/actuator/beans | jq '.contexts.application.beans.myBeanName'
Fix now
Check 'scope', 'type', and 'dependencies' fields in the response to verify bean is registered correctly
Application hangs at startup — possible circular dependency or blocking init+
Immediate action
Run with -Dspring.main.lazy-initialization=true to bypass and identify the blocking bean
Commands
java -jar app.jar --spring.main.lazy-initialization=true --logging.level.org.springframework=DEBUG 2>&1 | grep -E 'Creating|Initializing|PostConstruct'
curl -s http://localhost:8080/actuator/startup | jq '.timeline.events[] | select(.startupStep.name == "spring.beans.instantiate") | {name: .startupStep.tags[0].value, duration: .duration}'
Fix now
Add @Lazy to the dependency that's causing the deadlock, or use constructor injection with @Lazy on the parameter
Verify @PreDestroy is being called on shutdown+
Immediate action
Add a log line in @PreDestroy and send SIGTERM
Commands
kill -15 $(pgrep -f 'java.*myapp.jar')
grep 'PreDestroy\|Destroying\|Shutdown' /var/log/app/app.log | tail -20
Fix now
If nothing appears, check ENTRYPOINT in Dockerfile uses exec form, not shell form
Bean Lifecycle Hooks Comparison
HookWhen It RunsUse CaseGotcha
ConstructorBefore DIConstructor injection (preferred)No AOP proxy, no @Autowired fields yet
@PostConstructAfter DI, before proxyBean-local init with injected deps@Transactional doesn't work here
InitializingBean.afterPropertiesSet()Same as @PostConstructFramework code (avoid in app code)Couples bean to Spring API
ApplicationReadyEventAfter full context readyBusiness logic needing all beansAsync safe, runs after SmartLifecycle
SmartLifecycle.start()After context refreshStart background workers, consumersNeeds isRunning() implemented correctly
@PreDestroyBefore bean destroyedResource cleanupNot called for prototype beans
DisposableBean.destroy()Same as @PreDestroyFramework code cleanupCouples to Spring API
@Bean(destroyMethod)Same as @PreDestroyThird-party beans cleanupSpring auto-detects 'close'/'shutdown' methods

Key takeaways

1
Spring bean lifecycle order
constructor → DI → @PostConstruct → in-use → @PreDestroy. AOP proxies are created after @PostConstruct, so @Transactional doesn't work inside @PostConstruct.
2
@PreDestroy is never called for prototype-scoped beans
manage their cleanup manually. For singletons, it requires SIGTERM (not SIGKILL) and exec-form ENTRYPOINT in Docker.
3
Inject prototype beans into singletons via ObjectProvider<T>.getObject(), never via @Autowired directly
direct injection gives you the same prototype instance on every call.
4
Global lazy initialization (spring.main.lazy-initialization=true) speeds startup but hides wiring errors. Counter it by marking critical beans @Lazy(false) and having integration tests that exercise all beans.
5
SmartLifecycle with phase ordering is the right tool for sequencing startup of Kafka consumers, cache warmers, and other non-injection-connected components that have ordering requirements.

Common mistakes to avoid

6 patterns
×

Calling @Transactional methods inside @PostConstruct

Symptom
Database operations in @PostConstruct silently run without a transaction, causing data inconsistency or failing with 'No EntityManager with actual transaction available'
Fix
Use TransactionTemplate programmatically inside @PostConstruct, or move the transactional initialization to an ApplicationReadyEvent listener where AOP proxies are active
×

Injecting prototype beans into singletons with @Autowired

Symptom
Prototype bean behaves like a singleton — same instance returned every time, state accumulates across calls
Fix
Inject ObjectProvider<YourPrototypeBean> and call provider.getObject() each time you need a fresh instance, or use @Lookup method injection
×

Not handling shutdown in thread pools created in @PostConstruct

Symptom
Application hangs on shutdown, or Kubernetes forcibly kills the pod (SIGKILL) after terminationGracePeriodSeconds, causing in-flight work to be lost
Fix
Add @PreDestroy that calls executorService.shutdown() followed by awaitTermination() with a timeout, then shutdownNow() if timeout exceeded
×

Using shell form ENTRYPOINT in Dockerfile

Symptom
@PreDestroy never fires, resources aren't cleaned up, graceful shutdown doesn't work — the JVM never receives SIGTERM
Fix
Change Dockerfile ENTRYPOINT to exec form: ENTRYPOINT ["java", "-jar", "/app/app.jar"]
×

Relying on @Lazy globally without marking critical beans as eager

Symptom
Wiring errors (missing beans, configuration mistakes) are discovered at runtime under load, causing 500 errors in production instead of failed deployments
Fix
Use @Lazy(false) on beans that do critical validation in @PostConstruct, or add integration tests that load the full context with TestContext
×

Ignoring @DependsOn when beans have implicit ordering requirements

Symptom
Race condition at startup where one bean tries to use another that isn't fully initialized yet — intermittent startup failures that don't reproduce consistently
Fix
Add @DependsOn('requiredBeanName') to the dependent bean, or better, express the dependency through injection rather than @DependsOn
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the order of Spring bean lifecycle phases?
Q02SENIOR
Why doesn't @Transactional work inside @PostConstruct?
Q03SENIOR
When is @PreDestroy NOT called, and how do you handle cleanup in those c...
Q04SENIOR
How do you inject a prototype bean into a singleton?
Q05SENIOR
What is BeanPostProcessor used for, and how does it differ from @PostCon...
Q06SENIOR
What is SmartLifecycle and when would you use it?
Q07SENIOR
How does Spring handle circular dependencies, and why does constructor i...
Q08SENIOR
What happens to beans registered with @Scope('prototype') when the Appli...
Q09SENIOR
How do you implement graceful shutdown in a Spring Boot Kubernetes deplo...
Q01 of 09JUNIOR

What is the order of Spring bean lifecycle phases?

ANSWER
Instantiation (constructor) → dependency injection (@Autowired fields/setters) → BeanPostProcessor.postProcessBeforeInitialization() → @PostConstruct / InitializingBean.afterPropertiesSet()BeanPostProcessor.postProcessAfterInitialization() (AOP proxies created here) → bean in use → @PreDestroy / DisposableBean.destroy() on context close.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I have multiple @PostConstruct methods in one class?
02
What's the difference between @PostConstruct and implementing InitializingBean?
03
Does @PostConstruct run on every request for request-scoped beans?
04
How do I ensure my bean's @PostConstruct waits for the database to be ready?
05
What's the difference between ApplicationReadyEvent and ApplicationStartedEvent?
🔥

That's Spring Boot. Mark it forged?

12 min read · try the examples if you haven't

Previous
Spring Boot Caching with Redis
16 / 21 · Spring Boot
Next
Autowiring in Spring Boot