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.
- 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
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'.
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.
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.
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.
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.
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.
SmartLifecycle.start() calls), use @EventListener(ApplicationReadyEvent.class). This is cleaner than SmartLifecycle for simple use cases and doesn't require implementing an interface.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.
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.
Scheduler Fires Before Database is Ready
- 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.
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.curl -s http://localhost:8080/actuator/beans | jq '.contexts.application.beans | keys[]' | grep -i mybeancurl -s http://localhost:8080/actuator/beans | jq '.contexts.application.beans.myBeanName'Key takeaways
Common mistakes to avoid
6 patternsCalling @Transactional methods inside @PostConstruct
Injecting prototype beans into singletons with @Autowired
Not handling shutdown in thread pools created in @PostConstruct
Using shell form ENTRYPOINT in Dockerfile
Relying on @Lazy globally without marking critical beans as eager
Ignoring @DependsOn when beans have implicit ordering requirements
Interview Questions on This Topic
What is the order of Spring bean lifecycle phases?
BeanPostProcessor.postProcessBeforeInitialization() → @PostConstruct / InitializingBean.afterPropertiesSet() → BeanPostProcessor.postProcessAfterInitialization() (AOP proxies created here) → bean in use → @PreDestroy / DisposableBean.destroy() on context close.Frequently Asked Questions
That's Spring Boot. Mark it forged?
12 min read · try the examples if you haven't