Spring Bean Lifecycle Explained: From Instantiation to Destruction (With Real Code Examples)
Most Spring developers know how to use beans - annotate a class with @Service, inject it with @Autowired, and Spring handles the rest. But very few understand what "the rest" actually means. What exactly does Spring do between reading your @Service annotation and making that bean available for injection? What happens when your application shuts down? And why does the order of these phases matter for production-grade code?
Understanding the Spring Bean lifecycle is not academic knowledge. It is what allows you to write initialization logic that runs after dependencies are injected (not before), cleanup code that reliably releases resources on shutdown, and scoped beans that behave exactly as intended across different request contexts. Every unexplained NullPointerException in @PostConstruct, every connection that wasn't closed on shutdown, and every request-scope bug that's impossible to reproduce locally - all of these trace back to a misunderstanding of Bean lifecycle.
This guide walks through every phase of the Spring Bean lifecycle - in order, with real code examples at each step, and a clear explanation of why each phase exists. By the end, Spring's "magic" will have a clear, traceable mechanism behind it. For the Java foundation that makes this content click, Board Infinity's guide on OOP concepts in Java and understanding servlets in Java are excellent companions - Spring's DI container is built entirely on Java's class and interface model, and the web-scoped beans in this guide are built directly on Java's servlet lifecycle.
Who This Guide Is For
This guide is for Spring developers who:
- Have been using Spring for a while but want to understand what happens under the hood
- Have encountered mysterious NullPointerExceptions or resource leak issues in Spring apps
- Are preparing for Spring or backend developer technical interviews
- Want to write production-grade Spring beans with proper initialisation and cleanup
- Want to understand the Java fundamentals that power Spring's lifecycle - Board Infinity's core Java concepts and syntax guide covers the Java class, object, and annotation model that Spring's component scanning and lifecycle phases are built on
1. Spring Bean Scopes - Singleton, Prototype, Request, Session
Before understanding the lifecycle, you need to understand bean scopes - because the scope determines how many lifecycle instances exist and when each phase runs.
The four most important scopes are: singleton (one instance for the entire application - the default), prototype (new instance every time the bean is requested), request (new instance per HTTP request - web apps only), and session (new instance per HTTP session - web apps only).
Scope choice has profound implications. A singleton bean goes through its lifecycle once at startup. A prototype bean goes through instantiation and initialisation every time it's injected or requested - but Spring does not manage its destruction. Web-scoped beans are created and destroyed with their corresponding HTTP context. The List<CartItem> items = new ArrayList<>() in the ShoppingCart session-scoped bean - and the List<String> rows = new ArrayList<>() in CsvExporter - use Java's ArrayList directly. Board Infinity's Java List guide explains why ArrayList is used here: session and prototype beans each get their own List instance, and ArrayList provides the ordered, mutable collection that each scope's state management requires.
| Scope | Instance Count | Created When | Destroyed When | Use For |
|---|---|---|---|---|
singleton |
One per application context | App startup (eager) or first request (lazy) | Application context closes | Stateless services, repositories, controllers |
prototype |
New instance per injection | Every time bean is requested | Not managed by Spring - caller is responsible | Stateful beans, command objects, batch processors |
request |
New instance per HTTP request | HTTP request start | HTTP request ends | Request-specific data (web apps only) |
session |
New instance per HTTP session | HTTP session creation | HTTP session timeout or invalidation | User session data (web apps only) |
// SINGLETON (default) - one instance shared across entire application @Service // implicitly @Scope("singleton") public class UserService { // ONE instance - safe only if this class holds NO mutable state } // PROTOTYPE - new instance every time this bean is injected or requested @Component @Scope("prototype") public class CsvExporter { private List<String> rows = new ArrayList<>(); // safe - new instance each time } // REQUEST scope - new instance per HTTP request (web apps only) @Component @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class RequestContext { private String correlationId; // unique per request public String getCorrelationId() { return correlationId; } public void setCorrelationId(String id) { this.correlationId = id; } } // SESSION scope - new instance per HTTP session @Component @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) public class ShoppingCart { private List<CartItem> items = new ArrayList<>(); }
If you inject a request-scoped or session-scoped bean into a singleton service, Spring can't directly inject the short-lived bean into the long-lived one - singletons are created at startup when no request exists yet. The fix is proxyMode = ScopedProxyMode.TARGET_CLASS, which injects a proxy object. The proxy forwards each method call to the actual request/session-scoped instance at runtime. Without this, you'll get a confusing BeanCreationException at startup.
2. Bean Instantiation - How Spring Creates a Bean
Bean instantiation is the first phase. Spring reads your class definitions (via @Component scanning or @Bean methods), then calls the constructor to create an instance. At this point, the object exists in memory but its dependencies have not yet been injected.
This phase distinction matters because a common mistake is placing logic in the constructor that depends on injected fields. At construction time, injected fields are null. The constructor should only set up the object's own state from its parameters - never call methods on injected dependencies.
Spring performs component scanning at startup by reading classes in the specified packages, identifying those with stereotype annotations, and registering them as bean definitions in the application context. The annotation reading that drives component scanning - how Spring reads @Service, @Repository, @Component at the class level - relies on Java reflection and the annotations model. Board Infinity's classes and objects in Java guide covers the Java class model that Spring's instantiation phase reads: every @Service-annotated class is a Java class definition, and Spring calls its constructor the same way new ProductService(...) would in plain Java.
@Service public class ProductService { private final ProductRepository productRepository; private final CacheService cacheService; private int cacheSize; // INSTANTIATION PHASE: Spring calls this constructor // At this point ONLY constructor parameters are available public ProductService(ProductRepository productRepository, CacheService cacheService) { this.productRepository = productRepository; this.cacheService = cacheService; // Safe: both dependencies arrive as constructor parameters // productRepository and cacheService are NOT null here } // WRONG - calling injected dependencies in constructor (field injection) // This pattern causes NullPointerException because field injection // happens AFTER the constructor runs, not during it // @Autowired // DON'T do field injection // private CacheService cache; // null in constructor // public ProductService() { // cache.initialise(); // NullPointerException! // } }
3. Dependency Injection Phase
After instantiation, Spring enters the dependency injection phase. If you use constructor injection (recommended), injection happens during instantiation - the dependencies are passed as constructor arguments. If you use field or setter injection, Spring injects them after the constructor runs, using reflection.
This is the critical ordering to memorise: constructor runs first, then field/setter injection occurs. Any logic that needs an injected dependency must run after injection completes - not in the constructor if you're using field injection. Understanding how Java interfaces power the injection mechanism helps explain why Spring resolves by type first - it finds the bean that matches the declared interface or class type and injects it. Board Infinity's multiple inheritance in Java guide explains the interface model directly: when OrderService declares private final UserRepository userRepo, Spring finds the bean that implements the UserRepository interface and injects it - exactly the same mechanism as Java interface-type references in plain code. The abstraction in Java guide reinforces why Spring injects the interface type rather than the concrete class: abstraction through interfaces is what makes beans swappable and testable.
// CONSTRUCTOR INJECTION - injection happens DURING instantiation @Service public class OrderService { private final UserRepository userRepo; // injected via constructor private final EmailService emailSvc; // injected via constructor // Both dependencies arrive as parameters - immediately available public OrderService(UserRepository userRepo, EmailService emailSvc) { this.userRepo = userRepo; // NOT null - arrived as parameter this.emailSvc = emailSvc; // NOT null - arrived as parameter // Safe to use userRepo here because it came in as a parameter System.out.println("UserRepo connected: " + userRepo.isConnected()); } } // FIELD INJECTION - injection happens AFTER constructor runs @Service public class ReportService { @Autowired private DatabaseService dbService; // null during constructor, set after public ReportService() { // dbService IS NULL HERE - Spring hasn't injected it yet // dbService.connect(); // NullPointerException! System.out.println("Constructor: dbService = " + dbService); // null } // After constructor, Spring sets: this.dbService = resolvedBean // Now dbService is available - but only from @PostConstruct onward }
Because constructor injection delivers dependencies as parameters, there is no "injection phase" separate from instantiation - they happen simultaneously. The bean is constructed with all its dependencies already present. This is why constructor injection is safer: it makes it structurally impossible to have a partially-constructed bean with null dependencies. The field is declared final, guaranteeing it's set exactly once and never null.
4. @PostConstruct and InitializingBean - Post-Init Hooks
After instantiation and injection, Spring calls post-initialisation hooks - methods you designate to run immediately after the bean is fully ready. This is the correct place to put initialisation logic that depends on injected dependencies.
@PostConstruct is the standard approach: annotate any void method and Spring calls it after injection completes. InitializingBean is the interface-based alternative: implement afterPropertiesSet(). Both achieve the same result - @PostConstruct is preferred because it doesn't couple your class to Spring's API.
Common use cases: warming up a cache after the cache service is injected, validating configuration values, establishing connections, registering the bean with a registry, or loading initial data. The ConcurrentHashMap<Long, Product> used as the cache in ProductCacheService is a thread-safe variant of Java's HashMap. Board Infinity's generics in Java guide covers the Map<Long, Product> generic type declaration directly - the type parameters <Long, Product> define what the cache holds, and understanding generics makes every Spring bean's typed collection declarations self-explanatory. The @Override on afterPropertiesSet() in ConfigValidationService is also significant - Board Infinity's overloading vs overriding guide explains why @Override matters: if the method signature doesn't exactly match InitializingBean's declared method, without @Override the annotation compiles silently as a new method rather than an override, and Spring's lifecycle hook never fires.
@Service public class ProductCacheService { private final ProductRepository productRepository; private final Map<Long, Product> cache = new ConcurrentHashMap<>(); public ProductCacheService(ProductRepository productRepository) { this.productRepository = productRepository; // productRepository is available - but calling DB here is bad practice // Use @PostConstruct for any logic that needs injected dependencies } // Runs AFTER constructor AND after injection - all dependencies ready @PostConstruct public void warmUpCache() { System.out.println("Phase 3: @PostConstruct - warming up product cache"); productRepository.findAll() .forEach(p -> cache.put(p.getId(), p)); System.out.println("Cache loaded: " + cache.size() + " products"); } public Optional<Product> get(Long id) { return Optional.ofNullable(cache.get(id)); } } // Alternative: InitializingBean interface (less preferred - couples to Spring) @Service public class ConfigValidationService implements InitializingBean { private final AppProperties appProperties; public ConfigValidationService(AppProperties appProperties) { this.appProperties = appProperties; } @Override public void afterPropertiesSet() { // Equivalent to @PostConstruct - runs after injection if (appProperties.getApiKey() == null) { throw new IllegalStateException("app.api.key must be configured"); } System.out.println("Config validated - API key present"); } }
One of the most valuable uses of @PostConstruct is validating required configuration at startup. If a required API key, database URL, or external service endpoint is missing, throw an IllegalStateException in your @PostConstruct method. This causes the application to fail at startup with a clear error message - far better than failing at runtime when the first request hits the missing config, potentially hours or days after deployment.
5. @PreDestroy and DisposableBean - Cleanup Hooks
When the Spring application context shuts down - either from a kill signal, a graceful shutdown, or the application context being closed programmatically - Spring calls destruction hooks on all singleton beans before they are removed from memory.
@PreDestroy is the standard approach: annotate the method you want Spring to call before the bean is destroyed. DisposableBean is the interface-based alternative: implement destroy(). Use @PreDestroy for the same reason you prefer @PostConstruct over InitializingBean - it doesn't couple your class to Spring.
Critical use cases: closing database connection pools, flushing write-through caches, releasing file handles, stopping background threads, deregistering the bean from external services. The ScheduledExecutorService used in BackgroundSyncService connects directly to Java's threading model. Board Infinity's access modifiers in Java guide provides useful context here - the private access modifier on scheduler protects it from external access, which is important for lifecycle management: only the bean's own start() and stop() methods should control the executor, preventing external code from starting or stopping the background thread inappropriately. The what is composition in Java guide is also relevant - BackgroundSyncService owns its ScheduledExecutorService as a composed resource, and the @PostConstruct/@PreDestroy pair manages that resource's full lifecycle in the same way composition manages object ownership in Java.
@Service public class DatabaseConnectionPool { private HikariDataSource dataSource; @PostConstruct public void initialise() { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:postgresql://localhost/mydb"); config.setMaximumPoolSize(10); this.dataSource = new HikariDataSource(config); System.out.println("Connection pool opened with 10 connections"); } // Called BEFORE Spring destroys this bean - application shutdown @PreDestroy public void shutdown() { if (dataSource != null && !dataSource.isClosed()) { dataSource.close(); // release all DB connections gracefully System.out.println("Connection pool closed - all connections released"); } } } // Another example - background scheduler that must be stopped cleanly @Service public class BackgroundSyncService { private ScheduledExecutorService scheduler; @PostConstruct public void start() { scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(this::syncData, 0, 30, TimeUnit.SECONDS); System.out.println("Background sync started"); } @PreDestroy public void stop() { if (scheduler != null) { scheduler.shutdown(); // stop accepting new tasks System.out.println("Background sync stopped cleanly"); } } private void syncData() { /* sync logic */ } }
Spring manages the full lifecycle of singleton beans - including destruction. But for prototype beans, Spring only manages creation. Once a prototype bean is handed to the requesting code, Spring forgets about it. @PreDestroy is never called on prototype beans. If your prototype bean holds resources that need cleanup (open connections, file handles, threads), you must implement DisposableBean and call destroy() yourself, or use a different scope.
6. BeanPostProcessor - Intercepting Beans Before and After Initialisation
BeanPostProcessor is Spring's most powerful lifecycle extension point. It allows you to intercept every bean in the application context at two points: just before @PostConstruct runs (postProcessBeforeInitialization) and just after it runs (postProcessAfterInitialization).
Spring itself uses BeanPostProcessor extensively internally - it's how @Autowired injection is processed, how @Transactional wraps methods in proxies, and how AOP aspects are applied. When you write a custom BeanPostProcessor, you're using the same mechanism Spring uses to power its most important features.
The ServiceInterfaceValidator BeanPostProcessor - which checks that every @Service bean implements Auditable - uses Java reflection directly: bean.getClass().isAnnotationPresent(Service.class) and bean instanceof Auditable. Board Infinity's understanding polymorphism in Java guide covers the instanceof operator and runtime type checking used here - bean instanceof Auditable is polymorphism in action: it checks whether the bean's runtime type implements the Auditable interface, regardless of the declared type. The abstraction vs encapsulation guide is also relevant - the BeanPostProcessor intercepts beans through their public interface (Object bean) without knowing their concrete type, which is abstraction enabling the post-processor to work universally across all beans in the context.
// BeanPostProcessor intercepts EVERY bean - before and after @PostConstruct @Component public class BeanAuditPostProcessor implements BeanPostProcessor { // Called BEFORE @PostConstruct - bean is constructed and injected @Override public Object postProcessBeforeInitialization(Object bean, String beanName) { System.out.printf("Before init: [%s] type=%s%n", beanName, bean.getClass().getSimpleName()); return bean; // return the bean (or a wrapped version) } // Called AFTER @PostConstruct - bean is fully initialised @Override public Object postProcessAfterInitialization(Object bean, String beanName) { System.out.printf("After init: [%s] ready%n", beanName); return bean; // return original or wrap with proxy } } // Practical example: enforce that @Service beans implement Auditable @Component public class ServiceInterfaceValidator implements BeanPostProcessor { @Override public Object postProcessBeforeInitialization(Object bean, String name) { if (bean.getClass().isAnnotationPresent(Service.class)) { if (!(bean instanceof Auditable)) { throw new IllegalStateException( "@Service bean [" + name + "] must implement Auditable" ); } } return bean; } }
Because BeanPostProcessor beans are instantiated very early in the Spring startup process - before most other beans - Spring cannot inject regular beans into them via @Autowired. If you declare an @Autowired dependency in a BeanPostProcessor, Spring will log a warning and may skip proxying for that dependency. Keep BeanPostProcessor implementations simple and self-contained. Use constructor injection for any dependencies they absolutely require.
7. Common Mistakes with Bean Scopes in Production
Understanding the lifecycle theoretically is one thing. Knowing the mistakes that trip up experienced developers in production is another. Here are the four most common scope-related bugs.
Injecting a Prototype Bean into a Singleton: The most common scope mistake. Because the singleton is created once, its dependencies are also resolved once. The prototype bean gets injected once at startup - and then the same instance is reused for every call, defeating the purpose of the prototype scope entirely.
Storing Request-Specific State in a Singleton: A singleton service that stores the current user's ID in an instance variable will cause data leaks between users. In high-concurrency scenarios, user A's request could overwrite user B's data.
The ThreadLocal<Long> solution for storing request-specific data in a singleton - with set(), get(), and remove() methods - is one of the most important Java concurrency patterns in Spring development. Board Infinity's understanding wrapper class in Java guide covers why ThreadLocal<Long> uses Long (not long): ThreadLocal is a generic class that requires an object type parameter - primitives can't be used as generic type arguments, so the Long wrapper class is required. The singleton class in Java guide provides the foundational context for understanding why singleton beans must never hold mutable request state - the singleton pattern guarantees one instance shared by all callers, which is precisely what makes mutable state in singletons a thread-safety problem at scale. For the prototype injection fix using ApplicationContext.getBean(), Board Infinity's essentials of back-end development guide covers how the Spring application context fits into the full backend architecture.
// WRONG - prototype injected into singleton: always same instance @Service // singleton public class ReportService { @Autowired private CsvExporter csvExporter; // @Scope("prototype") - but injected ONCE // csvExporter is now effectively a singleton - all reports share it // CsvExporter.rows accumulates data across all reports! } // CORRECT FIX 1: Use ApplicationContext to get a new instance each time @Service public class ReportService { private final ApplicationContext context; public ReportService(ApplicationContext context) { this.context = context; } public void generateReport() { CsvExporter exporter = context.getBean(CsvExporter.class); // fresh each time exporter.export(data); } } // CORRECT FIX 2: Use @Lookup annotation - Spring overrides the method @Service public abstract class ReportService { @Lookup // Spring generates a method that returns a new CsvExporter each call public abstract CsvExporter getCsvExporter(); public void generateReport() { CsvExporter exporter = getCsvExporter(); // new instance each call exporter.export(data); } } // WRONG - storing user state in singleton field (thread-safety bug) @Service public class UserContextService { private Long currentUserId; // DANGER: shared by ALL requests public void setUser(Long id) { this.currentUserId = id; } // race condition } // CORRECT - use ThreadLocal for request-specific data in singletons @Service public class UserContextService { private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>(); public void setUserId(Long id) { userIdHolder.set(id); } public Long getUserId() { return userIdHolder.get(); } public void clear() { userIdHolder.remove(); } // always clear after request }
If you use ThreadLocal to store request-specific data in a singleton, you must clear it after the request completes. Web servers (including Spring Boot's embedded Tomcat) use thread pools - threads are reused across requests. If you set a value in ThreadLocal and don't clear it, the next request on the same thread will see the previous request's data. Use a Filter or HandlerInterceptor with an afterCompletion hook to call ThreadLocal.remove().
The Complete Bean Lifecycle - Phase by Phase
Here's the complete sequence, from Spring reading your class to the bean being destroyed:
Phase 1 - Class Scanning: Spring scans packages for @Component stereotypes and reads @Bean methods in @Configuration classes.
Phase 2 - Instantiation: Spring calls the constructor. For constructor injection, dependencies are passed as arguments. For field/setter injection, the constructor runs with no dependencies yet.
Phase 3 - Dependency Injection: For field and setter injection, Spring injects the resolved bean instances after the constructor completes.
Phase 4 - BeanPostProcessor.postProcessBeforeInitialization: Every BeanPostProcessor in the context has a chance to inspect or wrap the bean before init hooks run.
Phase 5 - @PostConstruct / afterPropertiesSet: Spring calls your init methods. All dependencies are guaranteed to be available.
Phase 6 - BeanPostProcessor.postProcessAfterInitialization: Every BeanPostProcessor has a second chance to inspect or wrap the now-fully-initialised bean. This is where AOP proxies are applied.
Phase 7 - Bean In Use: The bean is registered in the application context and injected wherever needed.
Phase 8 - @PreDestroy / destroy: On application shutdown, Spring calls destroy hooks on all singleton beans in reverse creation order.
Further Reading
Board Infinity Guides:
- OOP Concepts in Java
- Core Java Concepts and Syntax
- Classes and Objects in Java
- Abstraction in Java
- Abstraction vs Encapsulation in Java
- Understanding Polymorphism in Java
- Multiple Inheritance in Java - Interfaces
- Method Overloading vs Overriding in Java
- Understanding Singleton Class in Java
- Understanding Servlets in Java
- What is Composition in Java?
- Access Modifiers in Java
- Generics in Java
- Understanding Wrapper Class in Java
- Learn About Java List
- Learn About Throw and Throws in Java
- Essentials of Back-End Development: From APIs to Databases
External Resources:
- Spring Framework Official Docs - Bean Lifecycle
- Spring Framework Official Docs - Bean Scopes
- Baeldung - Spring Bean Lifecycle
Spring Framework: Core & Web Development on Coursera
This free Coursera course by Board Infinity covers every concept in this guide - Bean scopes, dependency injection, lifecycle hooks, Spring MVC, validation, and error handling - applied through hands-on project work. You'll understand not just how Spring works but why it's designed the way it is.
โ Certificate available ยท โ Self-paced ยท โ Beginner-friendly
Conclusion
The Spring Bean lifecycle is not background knowledge - it is the operating model of every Spring application. Every @PostConstruct that runs, every @Transactional proxy that wraps your service methods, every @PreDestroy that closes your connection pool - all of these are phases in the lifecycle working exactly as designed.
The three things to take away from this guide: always use constructor injection so instantiation and injection happen simultaneously; use @PostConstruct for any initialisation that needs injected dependencies - never the constructor if using field injection; and understand bean scopes deeply enough to avoid the singleton state and prototype injection mistakes that cause subtle, hard-to-reproduce production bugs.
The developers who debug Spring startup failures quickly and write services that behave correctly at scale are the ones who understand what happens between annotating a class with @Service and that bean being available for injection. That understanding starts with the lifecycle - and it compounds into better architecture, cleaner code, and faster debugging across every Spring application you build. The Java fundamentals that underpin every phase of the lifecycle - OOP, interfaces, generics, access modifiers, threading - are covered comprehensively in Board Infinity's generics in Java guide and learn about throw and throws in Java guide, which covers the exception handling patterns that @PostConstruct validation and @PreDestroy cleanup rely on for safe startup and shutdown.