
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 orgetBeancall.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:
- Instantiation: the constructor is called.
- Dependency injection: annotated fields and constructor parameters get populated.
- Aware callbacks: if the bean implements interfaces like
BeanNameAwareorApplicationContextAware, Spring injects that information before initialization. - Post-processing (before initialization):
BeanPostProcessor.postProcessBeforeInitialization. - Initialization, in this order: the method annotated with
@PostConstruct, thenafterPropertiesSet()(if the bean implementsInitializingBean), then a custominit-method, if declared. - Post-processing (after initialization):
BeanPostProcessor.postProcessAfterInitialization. This is where, for example, AOP proxies typically get created. - Bean ready to use: available in the container until the context is closed.
- Destruction, when the context shuts down:
@PreDestroy, thendestroy()(if the bean implementsDisposableBean), then a customdestroy-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.