Spring BootBean Lifecycle
Spring Boot

Bean Lifecycle

Every Spring bean goes through a precise lifecycle — from BeanDefinition registration through instantiation, dependency injection, initialization callbacks, active use, and finally destruction. Understanding this lifecycle lets you write correct initialization and cleanup code, and diagnose subtle bugs around bean state and ordering.

The Complete Bean Lifecycle

A Spring bean's lifecycle has twelve distinct phases from configuration reading to destruction. Each phase has hooks that let you plug in custom behavior at precisely the right moment. Phase 1 — BeanDefinition creation: Spring reads your annotations and configuration, creating a BeanDefinition for each bean — metadata about how to create it, what scope it has, what its dependencies are. Phase 2 — BeanFactoryPostProcessor: Processors run against BeanDefinitions before any beans are instantiated. PropertySourcesPlaceholderConfigurer (which resolves ${...} placeholders) runs here. Phase 3 — Instantiation: Spring calls the constructor, creating the object. Dependencies are not injected yet. Phase 4 — Dependency injection: Spring injects all @Autowired fields, calls @Autowired setters, and performs @Value injection. Phase 5 — BeanPostProcessor (before init): All BeanPostProcessors run their postProcessBeforeInitialization method. AOP proxies, @Autowired processing, and validation happen here. Phase 6 — @PostConstruct: Your initialization method runs. All dependencies are injected and available. Phase 7 — InitializingBean.afterPropertiesSet(): If your bean implements InitializingBean, this method is called. Phase 8 — Custom init-method: If you declared an initMethod in @Bean(initMethod="..."), it's called here. Phase 9 — BeanPostProcessor (after init): postProcessAfterInitialization runs. This is where AOP proxies are actually created — the proxy wraps your bean. Phase 10 — Bean in use: The bean is ready, registered in the context, and injectable everywhere. Phase 11 — @PreDestroy: On shutdown, your cleanup method runs first. Phase 12 — DisposableBean.destroy(): If implemented, called after @PreDestroy.

Initialization Hooks — Three Ways

Spring provides three mechanisms for running code after a bean's dependencies are injected and before it's made available to the rest of the application. They run in order: @PostConstruct, then InitializingBean, then custom init-method.
Java
// WAY 1@PostConstruct (RECOMMENDED):
@Component
public class DatabaseConnectionPool {

    private final DataSourceProperties properties;
    private HikariDataSource pool;

    public DatabaseConnectionPool(DataSourceProperties properties) {
        this.properties = properties;
        // Don't initialize here — dependencies may not be fully ready
    }

    @PostConstruct
    public void initialize() {
        // Called AFTER constructor AND after all @Autowired injection
        // All dependencies are guaranteed to be set and ready
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(properties.getUrl());
        config.setUsername(properties.getUsername());
        config.setPassword(properties.getPassword());
        config.setMaximumPoolSize(10);
        this.pool = new HikariDataSource(config);
        System.out.println("Connection pool initialized: " + pool.getPoolName());
    }
}

// WAY 2 — InitializingBean interface:
@Component
public class CacheWarmer implements InitializingBean {

    private final ProductRepository productRepository;
    private final CacheManager cacheManager;

    public CacheWarmer(ProductRepository productRepository, CacheManager cacheManager) {
        this.productRepository = productRepository;
        this.cacheManager = cacheManager;
    }

    @Override
    public void afterPropertiesSet() {
        // Same timing as @PostConstruct — called after all injection
        List<Product> topProducts = productRepository.findTop100ByOrderBySalesDesc();
        Cache cache = cacheManager.getCache("products");
        topProducts.forEach(p -> cache.put(p.getId(), p));
        System.out.println("Cache warmed with " + topProducts.size() + " products");
    }
}

// WAY 3 — Custom init-method declared on @Bean:
@Configuration
public class InfrastructureConfig {

    @Bean(initMethod = "start")
    public SchedulerService schedulerService() {
        return new SchedulerService();
    }
}

public class SchedulerService {
    public void start() {
        // Called by Spring after injection — equivalent to @PostConstruct
        System.out.println("Scheduler started");
    }
}

// ORDER when all three are present on the same bean:
// 1. @PostConstruct
// 2. InitializingBean.afterPropertiesSet()
// 3. Custom init-method

Destruction Hooks — Three Ways

Destruction hooks run when the Spring context shuts down — on graceful application shutdown (SIGTERM, Ctrl+C, context.close()). They are the right place to release resources, flush buffers, and close connections.
Java
// WAY 1@PreDestroy (RECOMMENDED):
@Component
public class MessageQueueConsumer {

    private volatile boolean running = false;
    private ExecutorService executor;

    @PostConstruct
    public void start() {
        running = true;
        executor = Executors.newFixedThreadPool(5);
        executor.submit(this::consumeMessages);
        System.out.println("Message queue consumer started");
    }

    @PreDestroy
    public void stop() {
        // Called when ApplicationContext is closing
        // Spring Boot graceful shutdown waits for this before JVM exits
        running = false;
        executor.shutdown();
        try {
            if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
        System.out.println("Message queue consumer stopped — drained cleanly");
    }

    private void consumeMessages() {
        while (running) {
            // process messages...
        }
    }
}

// WAY 2 — DisposableBean interface:
@Component
public class SearchIndexManager implements DisposableBean {

    private final ElasticsearchClient client;

    public SearchIndexManager(ElasticsearchClient client) {
        this.client = client;
    }

    @Override
    public void destroy() {
        // Called after @PreDestroy — flush pending index operations
        client.indices().flush();
        client.close();
        System.out.println("Search index flushed and client closed");
    }
}

// WAY 3 — Custom destroyMethod declared on @Bean:
@Configuration
public class InfrastructureConfig {

    @Bean(destroyMethod = "shutdown")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.initialize();
        return executor;
    }
    // Spring calls executor.shutdown() on context close automatically
}

// ORDER when all three are present on the same bean:
// 1. @PreDestroy
// 2. DisposableBean.destroy()
// 3. Custom destroyMethod

// IMPORTANT: @PreDestroy is only called for singleton-scoped beans.
// Prototype-scoped beans are NOT destroyed by Spring —
// you are responsible for releasing their resources.

BeanPostProcessor — Intercepting Every Bean

BeanPostProcessor is Spring's most powerful extension point. It intercepts every bean in the container before and after initialization — used by Spring itself to implement @Autowired processing, AOP proxy creation, validation, and more.
Java
// BeanPostProcessor interface — intercepts every bean:
public interface BeanPostProcessor {
    // Called BEFORE @PostConstruct:
    Object postProcessBeforeInitialization(Object bean, String beanName);

    // Called AFTER @PostConstruct (where AOP proxies are created):
    Object postProcessAfterInitialization(Object bean, String beanName);
}

// Custom BeanPostProcessor — example: measure initialization time of every bean:
@Component
public class InitializationTimingPostProcessor implements BeanPostProcessor {

    private final Map<String, Long> startTimes = new ConcurrentHashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        startTimes.put(beanName, System.currentTimeMillis());
        return bean;   // return the bean unchanged (or return a modified version)
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        Long startTime = startTimes.remove(beanName);
        if (startTime != null) {
            long elapsed = System.currentTimeMillis() - startTime;
            if (elapsed > 100) {   // only log slow initializations
                System.out.printf("Slow bean init: %s took %dms%n", beanName, elapsed);
            }
        }
        return bean;
    }
}

// Built-in BeanPostProcessors Spring uses internally:
// AutowiredAnnotationBeanPostProcessor   — processes @Autowired, @Value, @Inject
// CommonAnnotationBeanPostProcessor      — processes @PostConstruct, @PreDestroy, @Resource
// PersistenceAnnotationBeanPostProcessor — processes @PersistenceContext, @PersistenceUnit
// AbstractAdvisorAutoProxyCreator        — creates AOP proxies for @Transactional, @Cacheable

// BeanFactoryPostProcessor — runs against BeanDefinitions BEFORE instantiation:
@Component
public class CustomBeanFactoryPostProcessor implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
        // Modify BeanDefinitions before any beans are created
        BeanDefinition userServiceDef = beanFactory.getBeanDefinition("userService");
        userServiceDef.setScope(BeanDefinition.SCOPE_PROTOTYPE);   // change scope
        System.out.println("Modified userService scope to prototype");
    }
}

Bean Scopes and Lifecycle

A bean's scope determines how many instances are created and how long they live. Each scope has different lifecycle behavior — especially around destruction callbacks.
Java
// SINGLETON (default) — one instance for the entire ApplicationContext:
@Component
// or @Scope("singleton") — explicit but redundant
public class UserService {
    // Created once at startup (or first use if @Lazy)
    // @PostConstruct called once
    // @PreDestroy called once on shutdown
    // Shared by every bean that injects UserService
}

// PROTOTYPE — new instance every time it's requested:
@Component
@Scope("prototype")
public class ReportGenerator {
    // New instance created every time @Autowired or context.getBean() is called
    // @PostConstruct called on each new instance
    // @PreDestroy NOT called — you are responsible for cleanup
    // Never shared — each caller gets their own instance
}

// REQUEST — one instance per HTTP request (web apps only):
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String requestId = UUID.randomUUID().toString();
    private long startTime = System.currentTimeMillis();
    // Created at request start, destroyed at request end
    // proxyMode required when injecting into singleton beans
}

// SESSION — one instance per HTTP session:
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserSessionData {
    private String userId;
    private List<String> recentSearches = new ArrayList<>();
    // Created when session starts, destroyed when session expires/invalidates
}

// Injecting a prototype into a singleton — the problem:
@Service  // singleton
public class OrderProcessor {

    @Autowired
    private ReportGenerator reportGenerator;  // prototype — but only injected ONCE
    // Every call to orderProcessor.process() uses the SAME ReportGenerator instance
    // The singleton holds it — the prototype behavior is effectively lost

    // FIX — look up a new prototype instance each time:
    @Autowired
    private ApplicationContext context;

    public void process(Order order) {
        ReportGenerator generator = context.getBean(ReportGenerator.class); // fresh instance
        generator.generate(order);
    }
}

Lifecycle Events and Listeners

Spring publishes ApplicationEvents at key points in the container lifecycle. You can listen to these events to run code at precise moments — after context refresh, before shutdown, or when specific beans become available.
Java
// Listen to lifecycle events with @EventListener:
@Component
@Slf4j
public class ApplicationLifecycleListener {

    // Called after the context is fully refreshed — all beans created and ready:
    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady(ApplicationReadyEvent event) {
        log.info("Application fully started — all beans initialized");
        // Safe to make external connections, start schedulers, warm caches
    }

    // Called when context starts closing (before @PreDestroy methods):
    @EventListener(ContextClosedEvent.class)
    public void onContextClosing(ContextClosedEvent event) {
        log.info("Context closing — flushing pending operations");
    }

    // Called when context is refreshed (earlier than ApplicationReadyEvent):
    @EventListener(ContextRefreshedEvent.class)
    public void onContextRefreshed(ContextRefreshedEvent event) {
        log.info("Context refreshed");
    }
}

// ApplicationRunner — runs code after ApplicationReadyEvent:
@Component
@Order(1)   // control execution order when multiple runners exist
public class DataMigrationRunner implements ApplicationRunner {

    private final MigrationService migrationService;

    public DataMigrationRunner(MigrationService migrationService) {
        this.migrationService = migrationService;
    }

    @Override
    public void run(ApplicationArguments args) {
        log.info("Running data migration...");
        migrationService.migrate();
        log.info("Data migration complete");
    }
}

// CommandLineRunner — simpler version (receives String[] args):
@Component
@Order(2)   // runs after DataMigrationRunner
public class CacheWarmupRunner implements CommandLineRunner {

    private final CacheService cacheService;

    public CacheWarmupRunner(CacheService cacheService) {
        this.cacheService = cacheService;
    }

    @Override
    public void run(String... args) throws Exception {
        log.info("Warming caches...");
        cacheService.warmAll();
        log.info("Cache warmup complete");
    }
}

// SmartLifecycle — fine-grained control over start/stop:
@Component
public class BackgroundJobScheduler implements SmartLifecycle {

    private volatile boolean running = false;

    @Override
    public void start() {
        running = true;
        // Start background threads, schedulers, consumers
    }

    @Override
    public void stop() {
        running = false;
        // Stop gracefully
    }

    @Override
    public boolean isRunning() { return running; }

    @Override
    public int getPhase() { return 0; }  // lower phases start first, stop last
}

Lazy Initialization

By default, Spring creates all singleton beans at startup — eager initialization. Lazy initialization delays bean creation until the first time the bean is requested, reducing startup time at the cost of slower first requests.
Java
// @Lazy on a single bean — created on first use:
@Service
@Lazy
public class ReportingService {
    // NOT created at startup
    // Created the first time something requests or injects ReportingService
    // Startup is faster — first request that needs ReportingService is slower
}

// @Lazy on an injection point — lazy proxy injected:
@Service
public class UserService {

    @Autowired
    @Lazy
    private ReportingService reportingService;
    // UserService is created eagerly at startup
    // A proxy is injected for reportingService
    // ReportingService itself is created on the first call to reportingService.xxx()
}

// Global lazy initialization — ALL beans lazy (Spring Boot 2.2+):
// application.properties:
// spring.main.lazy-initialization=true

// When to use lazy initialization:
// - Large applications with many beans where startup time matters
// - Beans that are rarely used (admin tools, reporting services)
// - Development environments where you want faster restarts

// When NOT to use lazy initialization:
// - Production critical path beans (startup errors surface at first request, not startup)
// - Beans with @PostConstruct that must run before the app accepts traffic
// - Database migration (Flyway) — must run before any DB access

// @Lazy in @Configuration:
@Configuration
public class AppConfig {

    @Bean
    @Lazy
    public ExpensiveService expensiveService() {
        // Created only when first requested — not at startup
        return new ExpensiveService();
    }
}

// Smart strategy — lazy globally, eager for critical beans:
// spring.main.lazy-initialization=true (in application.properties)

@Component
@Lazy(false)   // override global lazy — always eager:
public class DatabaseHealthChecker {
    @PostConstruct
    public void checkDatabase() {
        // This MUST run at startup — mark as eager
    }
}

Lifecycle in Practice — Complete Example

Here's a realistic bean that uses multiple lifecycle hooks together — showing the order they execute and the right things to do at each phase.
Java
@Service
@Slf4j
public class KafkaConsumerService implements InitializingBean, DisposableBean {

    private final KafkaProperties kafkaProperties;
    private final OrderService orderService;
    private KafkaConsumer<String, String> consumer;
    private ExecutorService executorService;
    private volatile boolean consuming = false;

    // Phase 3 — INSTANTIATION: constructor called, no injection yet
    public KafkaConsumerService(KafkaProperties kafkaProperties,
                                OrderService orderService) {
        this.kafkaProperties = kafkaProperties;
        this.orderService = orderService;
        log.info("KafkaConsumerService constructed");
        // DON'T use kafkaProperties here if it came from @Value
        // @Value injection hasn't happened yet for field-injected values
        // Constructor-injected values ARE available here
    }

    // Phase 6@PostConstruct: all injection complete
    @PostConstruct
    public void postConstruct() {
        log.info("@PostConstruct: validating configuration");
        // Safe to use all injected dependencies here
        if (kafkaProperties.getBootstrapServers() == null) {
            throw new IllegalStateException("Kafka bootstrap servers not configured");
        }
        // Do lightweight validation — NOT heavy initialization
    }

    // Phase 7 — InitializingBean: same timing, called after @PostConstruct
    @Override
    public void afterPropertiesSet() {
        log.info("afterPropertiesSet: creating Kafka consumer");
        Properties props = new Properties();
        props.put("bootstrap.servers", kafkaProperties.getBootstrapServers());
        props.put("group.id", kafkaProperties.getGroupId());
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        this.consumer = new KafkaConsumer<>(props);
        this.consumer.subscribe(List.of("orders"));
    }

    // Called by ApplicationRunner after ALL beans are ready:
    public void startConsuming() {
        consuming = true;
        executorService = Executors.newSingleThreadExecutor();
        executorService.submit(() -> {
            log.info("Kafka consumer loop started");
            while (consuming) {
                ConsumerRecords<String, String> records =
                    consumer.poll(Duration.ofMillis(100));
                records.forEach(record -> {
                    orderService.processOrderEvent(record.value());
                    consumer.commitSync();
                });
            }
        });
        log.info("Kafka consuming started on topic: orders");
    }

    // Phase 11@PreDestroy: first destruction hook
    @PreDestroy
    public void preDestroy() {
        log.info("@PreDestroy: stopping consumer loop");
        consuming = false;
    }

    // Phase 12 — DisposableBean: called after @PreDestroy
    @Override
    public void destroy() throws Exception {
        log.info("destroy: shutting down executor and closing consumer");
        if (executorService != null) {
            executorService.shutdown();
            executorService.awaitTermination(10, TimeUnit.SECONDS);
        }
        if (consumer != null) {
            consumer.close(Duration.ofSeconds(5));
        }
        log.info("Kafka consumer cleanly shut down");
    }
}

// ApplicationRunner that starts consuming after all beans are ready:
@Component
@Order(Ordered.LOWEST_PRECEDENCE)   // run last — after all other runners
public class KafkaStartupRunner implements ApplicationRunner {
    private final KafkaConsumerService kafkaConsumerService;

    public KafkaStartupRunner(KafkaConsumerService kafkaConsumerService) {
        this.kafkaConsumerService = kafkaConsumerService;
    }

    @Override
    public void run(ApplicationArguments args) {
        kafkaConsumerService.startConsuming();
    }
}