Back to IoC Container & Bean Lifecycle
Q2Question 2 of 4

What is the Spring Bean Lifecycle?

Key Concepts
  • The lifecycle is: instantiate, inject dependencies, aware callbacks, BeanPostProcessor.before, @PostConstruct, InitializingBean, custom init, BeanPostProcessor.after (AOP proxy), READY.
  • Destruction order is: @PreDestroy, DisposableBean.destroy, custom destroy-method.
  • The injected bean is a CGLIB proxy because BeanPostProcessor.postProcessAfterInitialization wraps it for @Transactional, @Cacheable, and @Async.
  • @PostConstruct is the preferred init hook because it is a Jakarta standard with no Spring coupling.
  • Never put blocking network calls in @PostConstruct because it delays startup and can trigger Kubernetes restart loops.
  • @PreDestroy may not run on SIGKILL, JVM crash (OOM), prototype-scoped beans, or Runtime.halt().
  • @DependsOn forces creation order when there is no direct injection dependency between beans.

Definition

Instantiate, inject dependencies, @PostConstruct, AOP proxy wrap, READY; on shutdown: @PreDestroy, destroy; the injected reference is a CGLIB proxy created in postProcessAfterInitialization, which is why self-invocation bypasses @Transactional.
  • Bean lifecycle -- the sequence of steps the Spring container follows from creating a bean to destroying it: instantiate, populate properties, aware callbacks, init hooks, AOP proxy wrapping, ready, and finally destruction hooks.
  • @PostConstruct -- the preferred initialization callback (Jakarta standard, no Spring coupling) that runs after all dependencies are injected.
  • AOP proxy -- a CGLIB subclass created in BeanPostProcessor.postProcessAfterInitialization that intercepts method calls for @Transactional, @Cacheable, and @Async.
  • Three init hooks -- @PostConstruct (preferred), InitializingBean.afterPropertiesSet() (couples to Spring), custom @Bean(initMethod) (for third-party classes).
  • @PreDestroy -- cleanup callback that runs on graceful shutdown but is skipped on SIGKILL, JVM crash, or for prototype-scoped beans.

Hiring a teller at National Bank of India: create the employee file (instantiate), assign a desk and equipment (inject dependencies), introduce to the branch manager (aware callbacks), run orientation training (@PostConstruct: connect to the exchange feed, warm the cache), then the teller is ready to serve customers. On retirement (@PreDestroy): hand over keys, log out of systems. If the bank catches fire (SIGKILL), nobody goes through the retirement process.

Understanding the Concepts

What is the Spring Bean Lifecycle?

The Spring bean lifecycle is the sequence of steps the container follows from creating a bean to destroying it. Understanding this sequence explains why @Transactional works, why self-invocation bypasses it, and why @PostConstruct is the right place for initialization logic.

The complete lifecycle runs in this order:

  1. Load bean definitions -- container scans @ComponentScan packages and reads @Bean methods.
  2. Instantiate -- container calls the constructor with resolved constructor arguments.
  3. Populate properties -- container injects @Autowired fields and setter dependencies.
  4. Aware callbacks -- container calls BeanNameAware, BeanFactoryAware, ApplicationContextAware.
  5. BeanPostProcessor.postProcessBeforeInitialization -- processes annotations like @PostConstruct.
  6. Init hooks -- @PostConstruct, then InitializingBean.afterPropertiesSet(), then custom initMethod.
  7. BeanPostProcessor.postProcessAfterInitialization -- AOP proxy created here by AbstractAutoProxyCreator.
  8. Bean READY -- stored in singleton cache, available for injection.
  9. Destruction -- @PreDestroy, DisposableBean.destroy(), custom destroyMethod.
JAVA
package com.bank.service;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.stereotype.Service;
import java.util.logging.Logger;

@Service
public class InterestCalculationService implements BeanNameAware {
    private static final Logger log = Logger.getLogger(InterestCalculationService.class.getName());
    private String beanName;

    public InterestCalculationService() {
        log.info("1. Constructor called");
    }

    @Override
    public void setBeanName(String name) {
        this.beanName = name;
        log.info("2. BeanNameAware: " + name);
    }

    @PostConstruct
    public void init() {
        log.info("3. @PostConstruct: loading interest rate table");
    }

    @PreDestroy
    public void cleanup() {
        log.info("4. @PreDestroy: clearing rate cache");
    }
}
TEXT
1. Constructor called
2. BeanNameAware: interestCalculationService
3. @PostConstruct: loading interest rate table
... application runs ...
4. @PreDestroy: clearing rate cache

Why is the injected bean a proxy?

After initialization, BeanPostProcessor.postProcessAfterInitialization runs. AbstractAutoProxyCreator checks if the bean has @Transactional, @Cacheable, @Async, or any AOP advice. If it does, the processor wraps the bean in a CGLIB proxy (a dynamically generated subclass) that intercepts method calls to apply the cross-cutting behavior.

This means the reference other beans receive is the proxy, not the raw object. When Rajesh calls transferService.transfer(), the call hits the proxy first, which opens a transaction, then delegates to the real method.

JAVA
package com.bank.service;

import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TransferService {
    private final AccountRepository repo;

    public TransferService(AccountRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public void transfer(String from, String to, BigDecimal amount) {
        var sender = repo.findById(from).orElseThrow();
        var receiver = repo.findById(to).orElseThrow();
        sender.debit(amount);
        receiver.credit(amount);
        repo.save(sender);
        repo.save(receiver);
    }

    // Self-invocation: this call BYPASSES the proxy
    public void batchTransfer() {
        transfer("SAV2001", "SAV2002", new BigDecimal("5000")); // NO transaction!
    }
}

When batchTransfer() calls transfer() internally, it calls this.transfer() on the raw object, not through the CGLIB proxy. The @Transactional annotation has no effect. The fix is to inject TransferService into itself with @Lazy or use ApplicationContext.getBean() to get the proxy reference.

What are the three init and destroy hooks?

Spring provides three initialization hooks that run in a specific order, and three corresponding destruction hooks.

Init hooks (in order):

| Order | Hook | Coupling | Use case |
|---|---|---|---|
| 1st | @PostConstruct | Jakarta standard | Preferred for all init logic |
| 2nd | InitializingBean.afterPropertiesSet() | Couples to Spring | Avoid in application code |
| 3rd | @Bean(initMethod = "init") | None | Third-party classes you cannot annotate |

Destroy hooks (in order):

| Order | Hook | Coupling | Use case |
|---|---|---|---|
| 1st | @PreDestroy | Jakarta standard | Preferred for cleanup |
| 2nd | DisposableBean.destroy() | Couples to Spring | Avoid in application code |
| 3rd | @Bean(destroyMethod = "close") | None | Third-party classes (DataSource, ExecutorService) |

JAVA
package com.bank.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataSourceConfig {

    @Bean(destroyMethod = "close") // HikariCP's close() called on shutdown
    public HikariDataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:postgresql://localhost:5432/nbi_banking");
        ds.setUsername("rajesh");
        ds.setMaximumPoolSize(20);
        return ds;
    }
}

What should and should NOT go in @PostConstruct?

@PostConstruct runs after all dependencies are injected but before the bean serves any request. It is the right place for fast, synchronous initialization that requires injected dependencies.

Good uses: loading a cache from the database, validating configuration, registering listeners.

Bad uses: blocking network calls to external services, long-running computations.

JAVA
package com.bank.service;

import com.bank.repository.InterestRateRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class InterestRateService {
    private final InterestRateRepository rateRepo;
    private final Map<String, BigDecimal> rateCache = new ConcurrentHashMap<>();

    public InterestRateService(InterestRateRepository rateRepo) {
        this.rateRepo = rateRepo;
    }

    // GOOD -- fast DB read, no external network call
    @PostConstruct
    void loadRates() {
        rateRepo.findAllActive().forEach(r ->
            rateCache.put(r.getProductCode(), r.getRate())
        );
    }
}

WRONG -- blocking external call in @PostConstruct:

JAVA
package com.bank.service;

import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class ExchangeRateService {
    private final RestTemplate restTemplate;

    public ExchangeRateService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // WRONG -- if the API takes 30 seconds, startup takes 30 seconds
    // In Kubernetes, the liveness probe may kill the pod before init completes
    @PostConstruct
    void loadRates() {
        var rates = restTemplate.getForObject(
            "https://api.exchangerate.host/latest", String.class
        );
    }
}

CORRECT -- use ApplicationReadyEvent for unreliable external calls:

JAVA
package com.bank.service;

import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class ExchangeRateService {
    private final RestTemplate restTemplate;

    public ExchangeRateService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    @Async
    @EventListener(ApplicationReadyEvent.class)
    void loadRates() {
        // Runs AFTER startup completes, in a separate thread
        var rates = restTemplate.getForObject(
            "https://api.exchangerate.host/latest", String.class
        );
    }
}

Why might @PreDestroy not run?

@PreDestroy runs during graceful shutdown when the JVM receives SIGTERM and Spring's shutdown hook executes. However, several scenarios skip it entirely:

  • SIGKILL (kill -9) -- the JVM terminates immediately with no shutdown hooks.
  • JVM crash (OutOfMemoryError) -- the JVM may not have enough memory to run cleanup.
  • Prototype-scoped beans -- Spring does not track prototypes after creation, so @PreDestroy never runs.
  • Runtime.halt() -- bypasses shutdown hooks entirely.
  • Kubernetes -- after the terminationGracePeriodSeconds (default 30s), Kubernetes sends SIGKILL.
JAVA
package com.bank.service;

import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import java.util.logging.Logger;

@Service
public class AuditService {
    private static final Logger log = Logger.getLogger(AuditService.class.getName());

    @PreDestroy
    void flushAuditLog() {
        log.info("Flushing pending audit entries to database...");
        // Keep this fast -- K8s may SIGKILL if it takes too long
        // Flush with a timeout, not an unbounded loop
    }
}

What does @DependsOn do?

@DependsOn forces the container to create one bean before another even when there is no direct injection dependency between them. This is useful when beans interact through side effects rather than direct references.

JAVA
package com.bank.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

@Configuration
public class BankConfig {

    @Bean
    public DatabaseMigration databaseMigration() {
        // Runs Flyway migrations
        return new DatabaseMigration();
    }

    @Bean
    @DependsOn("databaseMigration") // migrations must complete first
    public AccountRepository accountRepository() {
        return new JpaAccountRepository();
    }
}

@DependsOn is a code smell in most cases. If bean A truly needs bean B, inject B directly. Use @DependsOn only for infrastructure ordering like database migrations, cache warming, or external connection setup.

What are common mistakes with the bean lifecycle?

WRONG -- accessing injected fields in the constructor:

JAVA
package com.bank.service;

import com.bank.repository.AccountRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AccountService {
    @Autowired
    private AccountRepository repo; // NOT yet injected during constructor

    public AccountService() {
        repo.findAll(); // NullPointerException!
    }
}

CORRECT -- use @PostConstruct or constructor injection:

JAVA
package com.bank.service;

import com.bank.repository.AccountRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service;

@Service
public class AccountService {
    private final AccountRepository repo;

    public AccountService(AccountRepository repo) {
        this.repo = repo; // injected via constructor -- available immediately
    }

    @PostConstruct
    void init() {
        // repo is guaranteed to be injected here
        long count = repo.count();
        System.out.println("Loaded " + count + " accounts");
    }
}
TEXT
Loaded 1247 accounts

What should a senior engineer know?

  • SmartInitializingSingleton. The afterSingletonsInstantiated() callback runs after ALL singletons are created. Use it when initialization depends on other beans being fully ready, not just injected.
  • Ordered destruction. Beans are destroyed in reverse creation order. If bean A depends on bean B, B is destroyed after A so that A can use B in its @PreDestroy.
  • BeanPostProcessor ordering. Multiple BeanPostProcessors run in @Order sequence. CommonAnnotationBeanPostProcessor (handles @PostConstruct) runs before AutowiredAnnotationBeanPostProcessor by default.
  • Lazy singletons. @Lazy on a bean defers its creation until first use. This can speed up startup but moves errors from startup time to request time.
  • Spring AOT (Spring Boot 3). At build time, the AOT engine generates code that replaces reflection-based bean creation. The lifecycle is the same, but the container starts faster because it skips classpath scanning.

What are common interview follow-up questions?

  • What is the difference between @PostConstruct and InitializingBean?
  • Why does self-invocation bypass @Transactional?
  • What happens if @PostConstruct throws an exception?
  • How do you ensure a DataSource bean is destroyed properly?
  • Can a prototype bean have @PreDestroy? (No, Spring does not manage prototype destruction.)
  • What is the difference between @Bean(initMethod) and @PostConstruct?
  • How does Spring decide which BeanPostProcessor runs first?

Visual Overview

DIAGRAM
  ┌─────────────────────────────────────────────────┐
  │           Spring Bean Lifecycle                   │
  │                                                   │
  │  1. Load bean definitions                         │
  │       │                                           │
  │       ▼                                           │
  │  2. Instantiate (constructor)                     │
  │       │                                           │
  │       ▼                                           │
  │  3. Populate properties (@Autowired fields)       │
  │       │                                           │
  │       ▼                                           │
  │  4. Aware callbacks (BeanNameAware, etc.)         │
  │       │                                           │
  │       ▼                                           │
  │  5. BeanPostProcessor.before                      │
  │       │                                           │
  │       ▼                                           │
  │  6. @PostConstruct                                │
  │     InitializingBean.afterPropertiesSet()         │
  │     Custom init-method                            │
  │       │                                           │
  │       ▼                                           │
  │  7. BeanPostProcessor.after ← AOP PROXY CREATED  │
  │       │                                           │
  │       ▼                                           │
  │  8. ════ BEAN READY (singleton cache) ════        │
  │       │                                           │
  │       ▼  (on shutdown)                            │
  │  9. @PreDestroy                                   │
  │     DisposableBean.destroy()                      │
  │     Custom destroy-method                         │
  └─────────────────────────────────────────────────┘

Key Takeaway

The Spring bean lifecycle follows a precise sequence. After loading bean definitions, the container instantiates each singleton by calling its constructor with resolved dependencies. Then it populates field and setter injected dependencies. Then BeanPostProcessors run their before hooks, which is where @PostConstruct is detected and invoked by CommonAnnotationBeanPostProcessor. After @PostConstruct, InitializingBean.afterPropertiesSet() runs, followed by any custom init-method. Then BeanPostProcessors run their after hooks, and this is where AOP proxies are created for beans that need @Transactional, @Cacheable, or @Async behavior. The proxy is what other beans receive through injection.

In Rajesh's banking interest rate service at National Bank of India, @PostConstruct loads active interest rates from the database into an in-memory ConcurrentHashMap cache. This must happen after construction because the repository dependency is needed, and it must complete before the service handles any customer request. The team keeps @PostConstruct fast and non-blocking. If the exchange rate API were unreliable, they would use ApplicationReadyEvent with @Async instead, so startup is not blocked. For shutdown, @PreDestroy flushes pending audit entries and clears the rate cache. They keep it bounded with a timeout because in Kubernetes, after the termination grace period, SIGKILL arrives and @PreDestroy is skipped entirely.

The three init hooks run in order: @PostConstruct first (preferred, Jakarta standard, no Spring coupling), then InitializingBean.afterPropertiesSet() (couples to Spring, avoid for application code), then custom init-method from the @Bean annotation (for third-party classes like HikariCP). Understanding this lifecycle explains why self-invocation bypasses @Transactional: the AOP proxy is created after initialization, and calls from within the same class go directly to the real object without passing through the proxy.

Remember one sentence: the bean lifecycle is instantiate, inject, @PostConstruct, AOP proxy, ready, and on shutdown @PreDestroy runs only if the JVM shuts down gracefully.

---

Share
Use ← → arrow keys to navigate