lang://
Archive

002 · 2026-07-24

Spring Boot: the container, beans and their lifecycle

Contents

Most people learn Spring's annotations before understanding what actually happens behind them. This is the introduction I wish I had read first: what the container is, what a bean is, and how Spring manages their lifecycle, going a bit deeper than the usual introduction does.

The IoC container

At its core, Spring is an Inversion of Control (IoC) container. Instead of your code creating and wiring objects manually with new, you describe your application's components and let the container instantiate, configure and wire them together.

The central interface is ApplicationContext. It reads the configuration (annotations, @Configuration classes, or XML, nowadays almost always the first two), builds the dependency graph, and keeps the managed objects alive for the whole application lifecycle.

ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = context.getBean(UserService.class);

In practice, with Spring Boot you almost never instantiate the context manually. @SpringBootApplication does that behind the scenes when you call SpringApplication.run(...).

Before any bean exists

Before the container instantiates the application's first "normal" bean, it has already processed the whole configuration through a BeanFactoryPostProcessor. Unlike BeanPostProcessor (which acts on instances that already exist, as you'll see in the lifecycle section below), BeanFactoryPostProcessor acts on bean definitions, the metadata that describes how each bean should be built, before any bean is actually instantiated.

The most common example is PropertySourcesPlaceholderConfigurer, which resolves placeholders such as ${jdbc.url} in bean definitions, replacing them with values coming from application.properties or other property sources. When you use @Value("${my.property}"), this is the mechanism working underneath.

@Configuration
public class AppConfig {
 
    @Bean
    public static PropertySourcesPlaceholderConfigurer propertyPlaceholder() {
        return new PropertySourcesPlaceholderConfigurer();
    }
}

Notice the static on the @Bean method. That's required because a BeanFactoryPostProcessor needs to be instantiated very early, before the container processes @Configuration annotations normally. Declaring it as a static method keeps the whole configuration class from being initialized too soon.

What a bean is

A bean is any object whose lifecycle is managed by the container. The most common ways to declare one are:

@Component
public class UserService {
    // Spring detects it via component scan and registers it as a bean
}
@Configuration
public class AppConfig {
 
    @Bean
    public UserService userService(UserRepository repository) {
        return new UserService(repository);
    }
}

@Service, @Repository and @Controller are specializations of @Component, semantically different, but technically identical to the container.

Bean scopes

By default, every bean is a singleton: a single instance per container, shared across every injection point. The most common scopes are:

  • singleton (default): one instance per container.
  • prototype: a new instance on every injection or getBean call.
  • request / session: web scopes, one instance per HTTP request or session.
@Component
@Scope("prototype")
public class ReportBuilder {
    // a fresh instance every time it's injected
}

A common problem shows up when a singleton bean depends on a request or session bean. Since the singleton is only created once, it can't simply inject an instance of those narrower scopes, which change on every request. Spring's answer is proxyMode: instead of injecting the real bean, the container injects a proxy that resolves the correct instance of the smaller scope on every method call.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCart {
    // the singleton injecting this bean gets a proxy,
    // not the actual instance for the current request
}

A bean's lifecycle

This is usually where things get confusing. The container doesn't just create a bean and forget about it, there's a well-defined sequence of steps:

  1. Instantiation: the constructor is called.
  2. Dependency injection: annotated fields and constructor parameters get populated.
  3. Aware callbacks: if the bean implements interfaces like BeanNameAware or ApplicationContextAware, Spring injects that information before initialization.
  4. Post-processing (before initialization): BeanPostProcessor.postProcessBeforeInitialization.
  5. Initialization, in this order: the method annotated with @PostConstruct, then afterPropertiesSet() (if the bean implements InitializingBean), then a custom init-method, if declared.
  6. Post-processing (after initialization): BeanPostProcessor.postProcessAfterInitialization. This is where, for example, AOP proxies typically get created.
  7. Bean ready to use: available in the container until the context is closed.
  8. Destruction, when the context shuts down: @PreDestroy, then destroy() (if the bean implements DisposableBean), then a custom destroy-method.
@Component
public class ExternalConnection implements InitializingBean, DisposableBean {
 
    @PostConstruct
    public void onStart() {
        System.out.println("1. @PostConstruct");
    }
 
    @Override
    public void afterPropertiesSet() {
        System.out.println("2. afterPropertiesSet");
    }
 
    @PreDestroy
    public void beforeShutdown() {
        System.out.println("3. @PreDestroy");
    }
 
    @Override
    public void destroy() {
        System.out.println("4. destroy()");
    }
}

A detail rarely mentioned outside the official docs: initialization callbacks, including @PostConstruct, run inside the singleton creation lock, and the bean is only considered fully ready after that callback returns. In practice this means looking up another bean that is itself still being created, from inside a @PostConstruct, can stall the whole application startup. If a bean needs to wait for other beans to be fully ready before running some logic, the safer path is listening for the ApplicationReadyEvent instead of doing that inside @PostConstruct itself.

In practice, day-to-day code almost always uses just @PostConstruct and @PreDestroy. The InitializingBean/DisposableBean interfaces couple your code to Spring and are more useful for library authors than for application code.

Ordering across beans: depends-on and SmartLifecycle

All of this covers the lifecycle of a single, isolated bean, but a real application has dozens or hundreds of them, and the order between them matters too. By default, Spring decides that order from the dependency graph: if bean A depends on bean B, B is created and initialized before A, and destroyed after it. When there's no direct dependency but the order still matters, you can force it with @DependsOn:

@Component
@DependsOn("externalConnection")
public class MigrationService {
    // guarantees "externalConnection" is already initialized before this bean
}

For beans that need an explicit start()/stop(), like a queue listener or a scheduler, Spring provides the SmartLifecycle interface. It adds the concept of a phase, via getPhase(): lower phases start first and stop last, higher phases start last and stop first. That's how a messaging listener can start only after the database infrastructure is ready, and stop before it during shutdown.

Why it matters

Understanding this lifecycle avoids subtle bugs: dependencies that aren't injected yet when the constructor runs, leaking connections because cleanup was never implemented, prototype beans being used as if they were singletons, or a @PostConstruct that stalls application startup by trying to reach another bean still being created. When a bean misbehaves in a way that doesn't match what you expected, you can usually point to a specific step in this lifecycle as the cause, instead of treating it as some unknowable magic from the framework.