Dependency Injection & Bean Lifecycle

The Bean Lifecycle

18 min Lesson 6 of 13

The Bean Lifecycle

Every object managed by the Spring container goes through a well-defined lifecycle: it is instantiated, its dependencies are populated, initialisation callbacks run, the bean serves the application, and finally destruction callbacks clean up resources. Understanding each phase lets you hook into exactly the right moment — whether you need to open a connection pool, validate configuration, or flush a cache before shutdown.

The Four Phases at a Glance

  1. Instantiation — the container creates the raw object using the constructor (or a factory method).
  2. Dependency population — Spring injects all dependencies: constructor arguments, setter calls, and field injection via @Autowired.
  3. Initialisation — once all dependencies are in place, initialisation callbacks fire. Your code can now use fully-injected collaborators.
  4. Destruction — when the context is closed (application shutdown, test teardown, etc.), destruction callbacks release resources.
Why the order matters: Initialisation callbacks run after injection, not during construction. If you try to use an injected field inside the constructor, it will still be null because Spring has not had a chance to set it yet.

Phase 1 — Instantiation

Spring creates the bean instance using reflection. For a singleton bean (the default scope) this happens once when the application context starts. For a prototype bean it happens each time one is requested. At this point the object is a plain Java object — no dependencies have been set.

@Component public class ReportService { private final DataSource dataSource; // Spring calls this constructor first (Phase 1 + Phase 2 combined for constructor injection) public ReportService(DataSource dataSource) { this.dataSource = dataSource; } }

Constructor injection collapses Phases 1 and 2 into a single step, which is one reason it is preferred: the object is never visible in a partially-initialised state.

Phase 2 — Dependency Population

After instantiation (for setter/field injection), Spring inspects the bean definition and resolves each dependency from the container, then calls the appropriate setter or writes directly to the field. This is when @Autowired, @Value, and @Inject are processed.

@Component public class NotificationService { // Populated by Spring after construction (Phase 2) @Autowired private MailSender mailSender; @Value("${notifications.max-retries:3}") private int maxRetries; }

Phase 3 — Initialisation

Once all dependencies are injected, Spring calls the initialisation callbacks in this order:

  1. Methods annotated with @PostConstruct (recommended)
  2. The afterPropertiesSet() method if the bean implements InitializingBean
  3. A custom init-method declared in @Bean(initMethod = "...")

@PostConstruct is the cleanest option: it is a standard Java annotation (jakarta.annotation.PostConstruct) independent of Spring, so the class stays portable and testable.

import jakarta.annotation.PostConstruct; import org.springframework.stereotype.Component; @Component public class CacheWarmer { private final ProductRepository productRepository; private Map<Long, Product> cache; public CacheWarmer(ProductRepository productRepository) { this.productRepository = productRepository; } @PostConstruct public void warmUp() { // Safe to use productRepository here — injection is complete cache = productRepository.findAll() .stream() .collect(Collectors.toMap(Product::getId, p -> p)); System.out.println("Cache warmed: " + cache.size() + " products loaded."); } }
Use @PostConstruct for: opening resources (thread pools, connection pools), loading configuration into memory, validating that required dependencies are correctly wired, or populating caches. Keep it fast — a slow @PostConstruct delays context startup.

Phase 4 — Destruction

When the Spring ApplicationContext is closed — on JVM shutdown, on a context.close() call in a test, or when a web application undeploys — destruction callbacks run for all singleton beans in reverse registration order:

  1. Methods annotated with @PreDestroy (recommended)
  2. The destroy() method if the bean implements DisposableBean
  3. A custom destroy-method declared in @Bean(destroyMethod = "...")
import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.springframework.stereotype.Component; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @Component public class AsyncTaskRunner { private ExecutorService executor; @PostConstruct public void start() { executor = Executors.newFixedThreadPool(4); System.out.println("AsyncTaskRunner started."); } @PreDestroy public void stop() throws InterruptedException { System.out.println("Shutting down AsyncTaskRunner..."); executor.shutdown(); if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { executor.shutdownNow(); } } }
Prototype beans are NOT destroyed by Spring. The container creates them and injects dependencies, but it does not hold a reference after handing the instance out. @PreDestroy will never be called on a prototype-scoped bean. If your prototype bean holds resources, you must manage its lifecycle manually.

Using @Bean Init and Destroy Methods

When integrating a third-party class that you cannot annotate (because you do not own its source), use the initMethod and destroyMethod attributes on @Bean:

import com.zaxxer.hikari.HikariDataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class InfrastructureConfig { @Bean(initMethod = "getConnection", destroyMethod = "close") public HikariDataSource dataSource() { HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl("jdbc:postgresql://localhost:5432/shop"); ds.setUsername(System.getenv("DB_USER")); ds.setPassword(System.getenv("DB_PASS")); ds.setMaximumPoolSize(10); return ds; } }
Auto-detected destroy method: Spring automatically infers close() or shutdown() as the destroy method for beans returned by @Bean methods, so you often do not need to specify destroyMethod explicitly. You can suppress this by setting destroyMethod = "".

The Full Lifecycle — One Complete Example

The following class deliberately exercises all four phases so you can observe the ordering in your logs:

import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.DisposableBean; import org.springframework.stereotype.Component; @Component public class LifecycleDemoBean implements InitializingBean, DisposableBean { public LifecycleDemoBean() { System.out.println("[1] Constructor called — raw object created"); } // @Autowired fields/setters would be populated here (Phase 2) @PostConstruct public void postConstruct() { System.out.println("[3a] @PostConstruct — first init callback"); } @Override public void afterPropertiesSet() { System.out.println("[3b] afterPropertiesSet() — second init callback"); } @PreDestroy public void preDestroy() { System.out.println("[4a] @PreDestroy — first destroy callback"); } @Override public void destroy() { System.out.println("[4b] destroy() — second destroy callback"); } }

Expected log output on startup and context.close():

[1] Constructor called — raw object created [3a] @PostConstruct — first init callback [3b] afterPropertiesSet() — second init callback [4a] @PreDestroy — first destroy callback [4b] destroy() — second destroy callback

Summary

The Spring bean lifecycle has four distinct phases: instantiation (constructor), dependency population (injection), initialisation (@PostConstructafterPropertiesSet() → custom init), and destruction (@PreDestroydestroy() → custom destroy). In practice, reach for @PostConstruct and @PreDestroy — they keep your beans portable and readable. Use @Bean(initMethod, destroyMethod) when integrating third-party classes. Remember that prototype beans opt out of Spring-managed destruction entirely.