Beans, lifecycle and scopes

Definition, instantiation, population, initialisation, destruction — and singleton, prototype, request scopes with their proxies.

8 min read🌱 Spring Core

A bean is not "an object Spring made". It is an object that passed through a defined sequence of phases, each of which is a place other code can intervene — and most of the surprising behaviour in a Spring application is something intervening at a phase you did not know existed. This lesson walks that sequence, then the scopes that decide how many times it runs and who gets the result.

A definition is not an instance

Before anything is constructed, the container builds an index of BeanDefinitions: class, scope, constructor arguments, init and destroy method names, lazy or not. They come from component scanning, from @Bean methods, and from anything that registers one programmatically.

This two-phase design is the reason several things work at all. Definitions can be modified before any object exists — that is what a BeanFactoryPostProcessor does, and how @Value("${…}") placeholders are resolved against properties before a constructor ever sees them. It is also why a bean whose class is missing from the classpath fails with a clear message about a definition rather than a NoClassDefFoundError halfway through startup.

BeanFactory is the container interface that holds definitions and produces beans. ApplicationContext extends it with the things an application needs — events, message sources, resource loading, and eager instantiation of singletons at startup.

The sequence

constructpopulateawareBPP beforeinitialiseBPP afterreadydestroy
instanceraw@Autowired fieldsnullproxiednoregistryin creation

construct. The constructor runs, with dependencies resolved and passed in. Field and setter dependencies are not set yet.

1 / 8

Two phases in that sequence explain most of what people find surprising.

Populate comes after instantiate, which is the half-built window from the previous lesson: an @Autowired field read inside a constructor is null, because nothing has written it yet.

BPP after can replace the object. postProcessAfterInitialization returns a bean, and nothing says it must be the one it received. @Transactional, @Async, @Cacheable and Spring Security's method annotations all work by returning a proxy here. The consequence is worth stating plainly: the object in the registry is usually not the object your constructor built, and that is the root of the self-invocation problem the AOP lesson covers.

@PostConstruct and @PreDestroy

These are the callbacks to use. They are plain annotations — jakarta.annotation — so the class does not implement a Spring interface and can be tested without one.

java
@Component
class ConnectionWarmer {
    private final DataSource dataSource;
    ConnectionWarmer(DataSource dataSource) { this.dataSource = dataSource; }
 
    @PostConstruct
    void warm() throws SQLException {
        try (var c = dataSource.getConnection()) { c.isValid(2); }
    }
 
    @PreDestroy
    void drain() { /* stop accepting, let in-flight finish */ }
}

The older equivalents — InitializingBean.afterPropertiesSet and DisposableBean.destroy — run in the order shown in the diagram and couple the class to Spring. @Bean(initMethod = …, destroyMethod = …) is the third form, and the right one for a class you do not own.

Scopes

ScopeOne instance perDestroyed by the container
singleton (default)containeryes
prototypeinjection or lookupno
requestHTTP requestyes, at request end
sessionHTTP sessionyes, at session end
applicationServletContextyes

The prototype row is the one that catches people. The container creates a prototype, wires it, initialises it — and then forgets it. There is no registry entry, so no destroy callback ever runs. A prototype holding something that must be closed is a leak the container will not help with; the class needs to be closed by whoever asked for it.

Scoped proxies, and the problem they solve

A singleton that injects a request-scoped bean is a contradiction: the singleton is created once, at startup, when no request exists.

java
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
class RequestContext {
    private String traceId;
    // …
}

The proxy mode changes what is injected. The singleton gets a proxy — one object, created at startup, that on every method call looks up the real request-scoped instance for the current thread and delegates to it. Without proxyMode, startup fails, because there is no request to create the bean from.

The same mechanism is what ObjectProvider<RequestContext> gives you more explicitly: ask for the instance when you need it rather than holding a reference to one.

Under the hood: what a BeanPostProcessor actually sees

A BeanPostProcessor is a bean itself, which creates an ordering problem the container has to solve: post-processors must exist before the beans they process. So the container instantiates them first, ahead of ordinary singletons, and a bean that a post-processor depends on is therefore created early — before the post-processors are all registered.

That produces one of the more confusing startup warnings in Spring:

plaintext
Bean 'myService' of type [MyService] is not eligible for getting processed
by all BeanPostProcessors (for example: not eligible for auto-proxying)

It means exactly what it says. Something pulled myService into existence early — usually a @Bean method in a configuration class that also defines a BeanPostProcessor, or a @Bean returning a post-processor without declaring it static. Because the bean was built before auto-proxying was in place, it is not proxied: its @Transactional methods are not transactional, and nothing fails. The fix is to make the post-processor-returning @Bean method static, so the configuration class need not be instantiated to call it.

@Order and the Ordered interface control the sequence among post-processors, which matters when two of them both want to wrap the same bean.

Walkthrough: the cache that was empty in production and full in tests

A team added @Cacheable to a pricing lookup. Integration tests showed the cache working. In production the hit rate was zero.

The bean was created by a @Bean method in a @Configuration class that also declared a BeanFactoryPostProcessor as a non-static @Bean. That forced the configuration class — and everything it produced — to be instantiated before the caching infrastructure's post-processor was registered. The pricing bean was created and never wrapped, so @Cacheable did nothing. In tests a different slice configuration created the bean the ordinary way, and it was proxied.

The startup log had said so, once, at INFO, in the line above.

Try it yourself

In what order?

java
@Component
class A implements InitializingBean {
    @Autowired private B b;
    A() { System.out.println("ctor " + b); }
    @PostConstruct void post() { System.out.println("post"); }
    @Override public void afterPropertiesSet() { System.out.println("aps"); }
}
Answer

ctor null, then post, then aps. The field is injected in the populate phase, which is after the constructor — so the constructor prints null. @PostConstruct runs before afterPropertiesSet, which is the documented order and the opposite of what most people guess. Take B as a constructor parameter and the first line becomes impossible to get wrong.

Why does close() never run?

java
@Bean @Scope("prototype")
ReportWriter reportWriter() { return new ReportWriter(); }   // implements AutoCloseable
Answer

The container does not keep prototypes, so it has no list to destroy and never calls close(). Each getBean produces an instance the caller owns. Either manage it yourself in a try-with-resources, or use ObjectProvider<ReportWriter> and close what you obtain. A destroyMethod on a prototype @Bean is silently ignored, which is worse than an error.

Which one is null?

A singleton injects a request-scoped bean without proxyMode. What happens, and when?

Answer

Not null — the application does not start. The container tries to create the request-scoped bean to inject it, finds no active request, and fails with ScopeNotActiveException wrapped in a bean creation failure. Adding proxyMode = ScopedProxyMode.TARGET_CLASS injects a proxy instead, which resolves the real instance per call; a call made off a request thread then fails at that call rather than at startup.

Misconceptions

  • "@PostConstruct runs after the bean is fully ready." It runs before postProcessAfterInitialization, so the proxy does not exist yet. A @PostConstruct method calling one of the bean's own @Transactional methods is not in a transaction.
  • "Prototype means a new instance every time I call the method." It means a new instance every time it is injected or looked up. A prototype injected once into a singleton is created once and then held forever.
  • "Singleton beans are thread-safe because Spring manages them." One instance shared across every request thread is the definition of shared mutable state if the bean has fields.
  • "@Lazy is a performance optimisation." It moves the cost from startup to the first call and turns a startup failure into a runtime one. Occasionally worth it; not free.
  • "The bean I get is the class I wrote." Often it is a CGLIB subclass of it. This matters the moment you compare classes, read annotations reflectively, or expect a final method to be intercepted.

Going deeper

  • Spring Framework reference, Core TechnologiesBean Overview, Customizing the Nature of a Bean, and Bean Scopes.
  • AbstractAutowireCapableBeanFactory#doCreateBean and #initializeBean in the framework source — the sequence above is those two methods, read top to bottom.
  • PostProcessorRegistrationDelegate, for why the early-instantiation warning exists and what triggers it.
Progress is saved on this device and to your account when signed in.