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.

Advertisement

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

Two phases: Spring builds a registry of recipes, then cooks beans from it@Configuration classes@Bean methods, @ImportComponent scan@Component, @ServiceBeanRegistrar (7.0)programmatic registrationBeanDefinition registryclass, scope, lazy, deps, initBeanFactoryPostProcessorsedit definitions, resolve ${...}phase 1phase 2: for each non-lazy singletonInstantiateconstructor injectionPopulatefield and setter injectionBPP before initAware callbacksInit@PostConstruct, afterPropertiesSetBPP after init: may return a PROXY@Transactional, @Async, @Cacheable, @RetryableSingleton cachewhat other beans receiveCallers get whatever the last post-processor returned, which is often not your class
Startup of an ApplicationContext. Phase 1 builds and edits bean definitions. Phase 2 creates singletons, and BeanPostProcessors may swap in proxies.

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.

Advertisement

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.

BeanFactoryPostProcessorBeanPostProcessor
SeesBeanDefinitions (recipes)Bean instances
RunsOnce, before any normal bean is createdTwice per bean, around init
Typical usePlaceholder resolution, registering or altering definitionsProxies for AOP, validation, injection of annotated fields
TrapDeclaring it as a non-static @Bean forces its configuration class to be created earlyBeans 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

  1. 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.
  2. Populate: field and setter injection, including @Autowired fields and @Value.
  3. Aware callbacks: BeanNameAware, BeanFactoryAware and, through a BPP, ApplicationContextAware.
  4. postProcessBeforeInitialization on every BPP. @PostConstruct is invoked at this point by a dedicated BPP.
  5. Init: InitializingBean.afterPropertiesSet(), then any custom init method.
  6. postProcessAfterInitialization on every BPP. This is where auto-proxying happens, so the bean may now be a proxy.
  7. After all singletons exist, SmartInitializingSingleton callbacks run, and SmartLifecycle beans start in phase order.
  8. 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 this never 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 new has 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

SymptomCauseFix
@Transactional, @Async or @Retryable has no effectSelf-invocation, or a private or final methodCall through another bean; make the method public and non-final
Data committed despite an exceptionChecked exception, or exception swallowedrollbackFor, or rethrow
UnexpectedRollbackExceptionInner REQUIRED method failed, outer caught and returnedLet it propagate, or use REQUIRES_NEW deliberately
"Not eligible for getting processed by all BeanPostProcessors"A BPP or BFPP pulls in ordinary beans earlyStatic @Bean for BFPPs; lazy lookups in BPPs
One user's data visible to anotherShort-lived bean injected into a singleton without a scoped proxy@RequestScope proxy or ObjectProvider
BeanCurrentlyInCreationExceptionConstructor injection cycleBreak the cycle by extracting a third bean

What to do next

  1. Search your codebase for @Transactional methods called from the same class and move them behind another bean.
  2. List every checked exception thrown from a transactional method and decide on rollbackFor for each.
  3. Turn on debug logging for org.springframework.transaction in a test and confirm that transactions start where you expect.
  4. Find singletons that hold request or prototype beans and switch them to scoped proxies or ObjectProvider.
  5. Move post-commit side effects to @TransactionalEventListener, and add an outbox where loss is unacceptable.
  6. On Spring Framework 7, replace hand-rolled retry loops with @Retryable under @EnableResilientMethods.
  7. Read Java annotations, in depth to see how frameworks discover the annotations the container acts on.
Key takeaway: The Spring container works in two phases: it builds and edits bean definitions, then creates instances and lets BeanPostProcessors wrap them in proxies. Transactions, async execution, caching and retries all live in those proxies, so they only apply to calls that enter through them. Know the lifecycle order, the scoped-proxy rule and the rollback defaults, and most mysterious Spring bugs become predictable.