Spring Boot gets most of the attention, but every Boot application runs on the Spring Framework's core container. When a transaction silently fails to roll back, a request-scoped bean leaks between users, or an @Async method runs synchronously, the cause is almost always in the container: how it builds beans, when it wraps them in proxies and which calls those proxies can see.
This article explains the container from first principles. It covers the two phases of startup, the two kinds of post-processor, the lifecycle of a bean, scopes, proxy-based AOP and transactions, application events, and what Spring Framework 7.0 (released 13 November 2025, the foundation of Spring Boot 4) added. Boot's own startup sequence and auto-configuration are covered in Spring Boot, in depth. This page is about the layer underneath it.
The problem the container solves
Without a container, the code that creates objects is scattered through the code that uses them. new OrderService(new JdbcOrderRepository(dataSource), new HttpPaymentClient(httpClient)) ties the service to concrete classes and makes the graph hard to test and harder to change. Inversion of control moves that wiring into one place. Each class declares what it needs, usually as constructor parameters, and the container works out the order of construction and supplies the dependencies.
The mechanism is not magic. It is reflection plus a registry, the same idea built step by step in the site's Java reflection deep dive. Spring adds two things that make it a framework rather than a factory. The first is a metadata layer, the bean definition, that can be inspected and changed before any object exists. The second is a set of hooks that can replace objects with proxies after they are built. Both are covered below.
Two phases: definitions first, then instances
In phase 1 the context reads configuration sources and turns each into a BeanDefinition. A bean definition is a recipe: the class or factory method, the scope, whether the bean is lazy, its dependencies and its init and destroy methods. Sources include @Configuration classes with @Bean methods, component scanning, @Import and, from 7.0, BeanRegistrar implementations. Then every BeanFactoryPostProcessor runs. These can read and modify definitions. ConfigurationClassPostProcessor, which processes @Configuration classes, is itself one of them.
In phase 2 the context instantiates every non-lazy singleton. For each one it calls the constructor, injects remaining dependencies, runs the Aware callbacks and init methods, and passes the object through every BeanPostProcessor. The object stored in the singleton cache, and handed to everyone who depends on it, is whatever the last post-processor returned.
BeanFactoryPostProcessor versus BeanPostProcessor
These two interfaces are easy to confuse, and confusing them causes real bugs. A BeanFactoryPostProcessor (BFPP) works on definitions in phase 1. A BeanPostProcessor (BPP) works on objects in phase 2.
| BeanFactoryPostProcessor | BeanPostProcessor | |
|---|---|---|
| Sees | BeanDefinitions (recipes) | Bean instances |
| Runs | Once, before any normal bean is created | Twice per bean, around init |
| Typical use | Placeholder resolution, registering or altering definitions | Proxies for AOP, validation, injection of annotated fields |
| Trap | Declaring it as a non-static @Bean forces its configuration class to be created early | Beans the BPP depends on are created before it and are not post-processed themselves |
// Phase 1: a BeanFactoryPostProcessor sees DEFINITIONS, before any bean exists.
@Component
class WarnOnPrototypeFactory implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
for (String name : bf.getBeanDefinitionNames()) {
BeanDefinition def = bf.getBeanDefinition(name);
if (def.isPrototype()) System.out.println("prototype bean: " + name);
}
}
}
// Phase 2: a BeanPostProcessor sees INSTANCES, and may wrap them.
@Component
class TimingPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String name) {
if (!bean.getClass().isAnnotationPresent(Timed.class)) return bean;
ProxyFactory pf = new ProxyFactory(bean);
pf.addAdvice((MethodInterceptor) inv -> {
long t0 = System.nanoTime();
try { return inv.proceed(); }
finally { System.out.printf("%s.%s %d us%n", name, inv.getMethod().getName(),
(System.nanoTime() - t0) / 1000); }
});
return pf.getProxy(); // other beans now receive this, not 'bean'
}
}The second trap in the table explains the log message "is not eligible for getting processed by all BeanPostProcessors". If a BPP injects a repository, that repository has to exist before the BPP does, so it misses proxies such as the transaction proxy. Keep post-processors free of dependencies, look them up lazily, and declare BFPP @Bean methods static.
The bean lifecycle, step by step
- Instantiate, through the constructor or factory method. Constructor injection happens here, which is why it is the preferred style: the object is never visible half-built.
- Populate: field and setter injection, including
@Autowiredfields and@Value. - Aware callbacks:
BeanNameAware,BeanFactoryAwareand, through a BPP,ApplicationContextAware. postProcessBeforeInitializationon every BPP.@PostConstructis invoked at this point by a dedicated BPP.- Init:
InitializingBean.afterPropertiesSet(), then any custom init method. postProcessAfterInitializationon every BPP. This is where auto-proxying happens, so the bean may now be a proxy.- After all singletons exist,
SmartInitializingSingletoncallbacks run, andSmartLifecyclebeans start in phase order. - On close, in reverse: lifecycle stop,
@PreDestroy,DisposableBean.destroy(), then any custom destroy method.
The ordering has one practical consequence. Inside @PostConstruct the bean is not yet proxied, so calling your own @Transactional method from there runs without a transaction. Work that needs the fully wrapped application belongs in an ApplicationReadyEvent listener (in Boot) or a SmartLifecycle bean.
Scopes and the scoped-proxy problem
A singleton is created once per container, and it is the default. A prototype is created on every lookup, and the container does not manage its destruction. Web contexts add request and session scopes. The classic bug happens when a short-lived bean is injected into a long-lived one. A request-scoped CurrentUser injected into a singleton controller would be resolved exactly once, at startup, when there is no request. Spring solves this with a scoped proxy. The singleton receives a proxy, and each method call on the proxy looks up the real instance for the current request.
Request-scoped components get that proxy through @RequestScope, whose default proxy mode is class-based. For prototypes, inject an ObjectProvider<T> and call getObject() each time you need a fresh instance. Injecting a prototype directly into a singleton gives you one instance, forever, which defeats the point.
Proxies: how @Transactional, @Async, @Cacheable and @Retryable work
Spring's declarative features are implemented the same way. A BPP finds beans whose methods carry an annotation, wraps each bean in a proxy, and puts an interceptor in front of those methods. If the bean implements interfaces, Spring may use a JDK dynamic proxy. Otherwise it generates a CGLIB subclass, and Spring Boot defaults to class-based proxies. Either way, the interceptor only runs when a call enters through the proxy. Three rules follow.
- Self-invocation is invisible. A call from one method of a bean to another on
thisnever touches the proxy. - Final and private methods cannot be intercepted by a CGLIB subclass, so annotations on them are ignored.
- Only container-managed instances are proxied. An object you create with
newhas no interceptors.
Here is the self-invocation bug as it usually appears:
@Service
public class OrderService {
private final OrderRepository orders;
private final PaymentClient payments;
public OrderService(OrderRepository orders, PaymentClient payments) {
this.orders = orders;
this.payments = payments;
}
public void checkout(Cart cart) { // no @Transactional here
validate(cart);
placeOrder(cart); // BUG: 'this.placeOrder', bypasses the proxy
}
@Transactional
public void placeOrder(Cart cart) {
orders.save(Order.from(cart));
payments.charge(cart.total()); // throws PaymentDeclinedException (checked)
}
}Calling checkout from a controller enters through the proxy, but checkout has no transactional annotation, so no transaction starts. Its internal call to placeOrder goes to this, the raw object, so the annotation on placeOrder is never seen. The order is saved in auto-commit mode and stays saved when the payment fails.
Transactions: propagation and rollback rules
The transaction interceptor asks a PlatformTransactionManager for a transaction according to the propagation setting. REQUIRED, the default, joins an existing transaction or starts one. REQUIRES_NEW suspends the outer transaction and starts an independent one, which suits audit records that must survive a rollback. It also takes a second connection, so heavy use can exhaust the pool. readOnly = true is a hint that some drivers and ORMs use to skip dirty checking or route to replicas.
Rollback is the most misunderstood part. By default Spring rolls back on unchecked exceptions and on Error, but not on checked exceptions. A method that throws a checked PaymentDeclinedException commits whatever it wrote unless you declare rollbackFor. Catching an exception inside the transactional method and not rethrowing it also commits. With REQUIRED, an inner method that throws marks the shared transaction rollback-only. If an outer method catches that exception and returns normally, the commit fails with UnexpectedRollbackException. Repository-level behaviour is covered in Spring Data, in depth.
Application events
The container includes a simple publish-subscribe bus. ApplicationEventPublisher.publishEvent(obj) delivers any object to every @EventListener method whose parameter type matches. Delivery is synchronous by default, on the publisher's thread and inside its transaction, so a slow or failing listener slows or fails the publisher. @TransactionalEventListener defers delivery to a transaction phase, AFTER_COMMIT by default. That makes it the right place to send emails or publish messages only for orders that really committed. Events are in-process only. If the process crashes after the commit but before the listener runs, the event is lost, so anything that must not be lost needs an outbox table.
What Spring Framework 7.0 changed
Version 7.0 added several things that matter to container users. BeanRegistrar gives first-class programmatic bean registration through a BeanRegistry and the Environment. It replaces hand-written BFPPs for conditional registration. The codebase is annotated with JSpecify nullness annotations, which tools and Kotlin understand. Resilience features moved into the core: @Retryable and @ConcurrencyLimit in org.springframework.resilience.annotation, switched on with @EnableResilientMethods. @Retryable defaults to three retries one second apart. A matching programmatic RetryTemplate is also available. Web controllers gained API versioning, and the stack now defaults to Jackson 3 with Jackson 2 support deprecated.
class ClientsRegistrar implements BeanRegistrar {
@Override
public void register(BeanRegistry registry, Environment env) {
registry.registerBean("httpClientConfig", HttpClientConfig.class);
if (env.matchesProfiles("eu")) {
registry.registerBean(RegionRouter.class,
spec -> spec.supplier(ctx -> new RegionRouter("eu-west-1")));
}
}
}
@Configuration
@Import(ClientsRegistrar.class)
@EnableResilientMethods // turns on @Retryable and @ConcurrencyLimit
class AppConfig { }
@Service
class InventoryClient {
@Retryable // defaults: up to 3 retries, 1 s apart, any exception
@ConcurrencyLimit(10) // at most 10 concurrent calls through the proxy
public Stock fetch(String sku) { ... }
}The resilience annotations are proxy-based like everything above. A self-invoked @Retryable method is not retried, and stacking it on a @Transactional method raises an ordering question: you usually want each retry to run in a fresh transaction. Security annotations follow the same proxy model; see Spring Security, in depth.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| @Transactional, @Async or @Retryable has no effect | Self-invocation, or a private or final method | Call through another bean; make the method public and non-final |
| Data committed despite an exception | Checked exception, or exception swallowed | rollbackFor, or rethrow |
| UnexpectedRollbackException | Inner REQUIRED method failed, outer caught and returned | Let it propagate, or use REQUIRES_NEW deliberately |
| "Not eligible for getting processed by all BeanPostProcessors" | A BPP or BFPP pulls in ordinary beans early | Static @Bean for BFPPs; lazy lookups in BPPs |
| One user's data visible to another | Short-lived bean injected into a singleton without a scoped proxy | @RequestScope proxy or ObjectProvider |
| BeanCurrentlyInCreationException | Constructor injection cycle | Break the cycle by extracting a third bean |
What to do next
- Search your codebase for
@Transactionalmethods called from the same class and move them behind another bean. - List every checked exception thrown from a transactional method and decide on
rollbackForfor each. - Turn on debug logging for
org.springframework.transactionin a test and confirm that transactions start where you expect. - Find singletons that hold request or prototype beans and switch them to scoped proxies or
ObjectProvider. - Move post-commit side effects to
@TransactionalEventListener, and add an outbox where loss is unacceptable. - On Spring Framework 7, replace hand-rolled retry loops with
@Retryableunder@EnableResilientMethods. - Read Java annotations, in depth to see how frameworks discover the annotations the container acts on.