Spring · 02

Bean Scopes & Lifecycle

A bean is not just an object: it has a lifetime. Scope decides how many instances exist and who shares them; the lifecycle decides what runs between "constructed" and "ready" and again at shutdown. This page walks every step in order, shows where proxies are born, why a prototype injected into a singleton goes stale, how to write BeanPostProcessors, how SmartLifecycle and graceful shutdown work, and proves the order with a runnable simulation.

11
Chapters
9
Diagrams
11
Lifecycle steps
10
Interview Q&A
01
Chapter One · The Problem

Why Scope and Lifecycle Matter

Creating an object is the easy part. Real beans own resources: a connection pool must be opened after its properties are set and closed on shutdown; a cache must be warmed before traffic arrives; per-request data must never leak between users. Two questions decide whether that works: how many instances exist and who shares them (scope), and what runs, in what order, around creation and destruction (lifecycle).

Bean lifetimes on one timeline
context.refresh() close() singleton session request prototype one instance, whole container lifetime (shared by all threads) HttpSession A HttpSession B HttpSession C one per HTTP request new object on every getBean() / injection; destroy callbacks never run
Where lifecycle bugs come from
@Service public class CheckoutService { private Order currentOrder; // BUG: singleton + mutable field = shared by every thread public Receipt checkout(Order order) { this.currentOrder = order; // thread A writes ... applyDiscounts(); // ... thread B overwrites it here ... return pay(currentOrder); // ... thread A charges B's order } } @Component public class CacheWarmer { private final ProductRepository repo; @Value("${shop.cache.size}") private int size; // injected AFTER the constructor public CacheWarmer(ProductRepository repo) { this.repo = repo; repo.loadTop(size); // BUG: size is still 0 here } }
Key insight: A hotel. The building (singleton) is shared by every guest; a room key card (request scope) is issued at check-in and dead at check-out; a paper towel (prototype) is handed over once and the hotel never sees it again. The checklist before opening the hotel (lifecycle callbacks) matters just as much as how many rooms exist.
Rules of thumb
  • The default scope is singleton. Treat every singleton as a shared, concurrently used object: no per-user mutable state in fields.
  • Initialisation logic belongs in a lifecycle callback or a constructor parameter, not in a constructor reading injected fields.
  • Anything that holds an OS resource (thread, socket, file, pool) needs a matching shutdown callback.
02
Chapter Two · Mental Model

One Assembly Line for Every Bean

Every bean passes through the same assembly line, driven by AbstractAutowireCapableBeanFactory.doCreateBean(). Memorise the shape: create → fill → introduce → process → initialise → process → ready → (later) destroy.

The factory instantiates the object
▶
Constructor runs (constructor-injected beans are resolved first, recursively). The object exists but has no injected fields yet
Properties are populated
▶
@Autowired / @Value fields and setters are filled by AutowiredAnnotationBeanPostProcessor, not by Spring core directly
Aware callbacks tell the bean who it is
▶
BeanNameAware, BeanFactoryAware, then ApplicationContextAware and friends. Few beans need them; infrastructure does
Post-processors wrap both sides of initialisation
▶
"Before": annotation processors including @PostConstruct. Then init methods run. "After": the last chance to replace the object, which is where proxies appear
The result goes into the singleton cache
▶
Whatever postProcessAfterInitialization returned is what everybody receives, possibly not the object you constructed
On close, reverse order
▶
Dependents are destroyed before the things they depend on: @PreDestroy, DisposableBean.destroy(), destroy-method
Singleton bean lifecycle, in the order Spring calls things
1 Instantiateconstructor / @Bean 2 Populate@Autowired fields, setters 3 Aware callbacksBeanName / Context 4 BPP beforepostProcessBefore... 5 @PostConstructjakarta.annotation 6 afterPropertiesSetInitializingBean 7 init-method@Bean(initMethod) 8 BPP afterproxies are born here READY: bean sits in singletonObjects and is injected into dependents (the proxy, if step 8 made one) context.close(), reverse creation order, singletons only 9 @PreDestroy 10 DisposableBean.destroy() 11 destroy-method
⚠ Production gotchas:
  • Reading injected fields in the constructor (step 1 vs step 2). Use constructor parameters, or move the code to @PostConstruct.
  • Assuming the bean you created is the bean others hold. After step 8 other beans may hold a CGLIB subclass or JDK proxy; this inside the bean is still the raw object (the self-invocation trap, see AOP).
  • Assuming a prototype is destroyed. Steps 9 to 11 never run for prototypes; the container does not track them.
03
Chapter Three · Scopes

Singleton, Prototype, Request, Session

A scope is a policy implemented by org.springframework.beans.factory.config.Scope: given a name, either return the existing instance or create one through an ObjectFactory. The container asks the scope; the scope owns the cache.

ScopeInstancesCreatedDestroyed by container?Available in
singleton (default)one per containerat startup (unless @Lazy)yes, at close()any context
prototypenew on every request for the beaneach getBean / injection resolutionnoany context
requestone per HTTP requestfirst use in the requestyes, at request endweb-aware context
sessionone per HTTP sessionfirst use in the sessionyes, at session endweb-aware context
applicationone per ServletContextfirst useyesweb-aware context
websocketone per WebSocket sessionfirst useyesWebSocket support
Declaring scopes
@Component // singleton: implicit public class PricingService { /* stateless: safe to share */ } @Component @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // or @Scope("prototype") public class ReportBuilder { // holds per-report state private final List<Row> rows = new ArrayList<>(); public ReportBuilder add(Row r) { rows.add(r); return this; } } @Configuration(proxyBeanMethods = false) class ScopeConfig { @Bean @RequestScope // = @Scope(value = "request", proxyMode = TARGET_CLASS) RequestContext requestContext() { return new RequestContext(); } @Bean @SessionScope ShoppingCart cart() { return new ShoppingCart(); } // must be Serializable in a clustered setup }
Singleton does not mean "one per JVM", and does not mean thread-safe

A singleton is one instance per bean definition per container. Two contexts (two tests with different configuration, a parent and child context) hold separate instances. All request threads call the same object concurrently. Safe singletons are stateless, immutable, or guard their state.

The fix for the shared-state bug: keep per-call state local
@Service public class CheckoutService { private final PricingService pricing; // collaborators: final, shared, thread-safe private final PaymentGateway gateway; public CheckoutService(PricingService pricing, PaymentGateway gateway) { this.pricing = pricing; this.gateway = gateway; } public Receipt checkout(Order order) { // everything per-call lives on the stack Money total = pricing.total(order); return gateway.charge(order.customerId(), total); } }
⚠ Production gotchas:
  • Mutable singleton fields. Counters, "current user", reusable SimpleDateFormat, a StringBuilder. Symptoms: intermittent wrong data under load, never reproducible in single-threaded tests. Fix: local variables, immutable objects, AtomicLong/ConcurrentHashMap, or a narrower scope.
  • Request/session scope outside a web request. From @Async, @Scheduled, a message listener or @PostConstruct: IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread?. Pass the data as arguments instead.
  • Session beans in a stateless API. A session-scoped bean forces sticky sessions or session replication. REST services normally keep no server-side session.
  • Choosing prototype for convenience. A prototype used as a factory (new per use) is fine; a prototype expected to be cleaned up by Spring is a leak.
04
Chapter Four · Mixing Lifetimes

The Prototype-in-Singleton Trap and Scoped Proxies

Injection happens once, when the singleton is created. If a singleton receives a shorter-lived bean directly, it keeps that one instance forever, and the scope you declared is quietly ignored. This is the single most common scope bug.

The prototype-in-singleton trap (from the plain-Java demo below: stale 1,1,1 vs fresh 2,3,4)
Plain injection of a prototype ReportService (singleton)field: Tracker tracker Tracker #1created once, at startup call 1 → #1 call 2 → #1 call 3 → #1 prototype resolved ONCE: stale shared state ObjectProvider / @Lookup / scoped proxy ReportService (singleton)field: ObjectProvider<Tracker> Tracker #2 Tracker #3 Tracker #4 getObject() each call container builds a new bean per call fresh state, as the scope promised
Three correct ways to get a fresh prototype per call
@Service public class ReportService { private final ObjectProvider<ReportBuilder> builders; // 1. look up at call time public ReportService(ObjectProvider<ReportBuilder> builders) { this.builders = builders; } public Report monthly() { ReportBuilder b = builders.getObject(); // new prototype instance return b.build(); } } @Component public abstract class ReportFactory { // 2. @Lookup: Spring overrides the method @Lookup protected abstract ReportBuilder newBuilder(); // generated subclass calls getBean(ReportBuilder.class) public Report monthly() { return newBuilder().build(); } } @Component @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS) // 3. proxy: every METHOD CALL gets a new instance public class ReportBuilder { /* ... */ }

For request and session scope the proxy is the idiomatic answer: a long-lived singleton must call into a short-lived bean. @RequestScope and @SessionScope already include proxyMode = TARGET_CLASS.

Request-scoped bean injected into a singleton through a scoped proxy
Request thread 1X-Tenant: acme Request thread 2X-Tenant: globex Singleton serviceone RequestContext field CGLIB proxyinjected instance RequestContextacme (req 1) RequestContextglobex (req 2) thread-local thread-local Each call on the proxy looks up the RequestAttributes of the CURRENT thread and forwards to that request's target
A request-scoped bean used from singletons
@Component @RequestScope public class RequestContext { private String tenantId; private String correlationId = UUID.randomUUID().toString(); public String tenantId() { return tenantId; } public void tenantId(String t) { this.tenantId = t; } public String correlationId() { return correlationId; } } @Component public class TenantInterceptor implements HandlerInterceptor { private final RequestContext ctx; // a proxy, safe to hold in a singleton public TenantInterceptor(RequestContext ctx) { this.ctx = ctx; } @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { ctx.tenantId(req.getHeader("X-Tenant")); // writes the CURRENT request's instance return true; } } @Service public class InvoiceService { private final RequestContext ctx; private final InvoiceRepository repo; public InvoiceService(RequestContext ctx, InvoiceRepository repo) { this.ctx = ctx; this.repo = repo; } public List<Invoice> mine() { return repo.findByTenant(ctx.tenantId()); } // resolves per request }
TechniqueResolution timeNeeds proxy-able classBest for
ObjectProvider<T>explicit callnoprototype factories, optional beans; clearest intent
@Lookupexplicit call (method)yes (subclassed)legacy-style factory methods
Scoped proxy (proxyMode)every method callyes (class not final, or an interface)request/session beans used from singletons
Inject ApplicationContext, call getBeanexplicit callnoavoid: couples you to Spring, hard to test
⚠ Production gotchas:
  • Scope not active. Without a proxy, a singleton asking for a request-scoped bean at startup fails: BeanCreationException: Error creating bean with name 'scopedTarget.requestContext': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton.
  • Prototype with a proxy loses state between calls. Each call on the proxy builds a new instance, so builder.add(x) then builder.build() acts on two different objects. Use ObjectProvider and hold the result in a local variable.
  • CGLIB cannot proxy a final class or method. Symptom: failure to create the proxy or silently non-proxied methods. Make the class non-final, or use ScopedProxyMode.INTERFACES with an interface.
  • this is not the proxy. Inside the scoped bean, this is the real instance; passing it elsewhere leaks the target outside its scope.
  • Threads you start yourself. A new thread has no request attributes, so request-scoped proxies fail inside it. Copy the data you need before handing work to the executor.
05
Chapter Five · Lifecycle

The Full Lifecycle, Step by Step

Below is the exact order for a singleton bean in a typical Spring Boot 3 application. Each row names the class that actually performs the step, which is what you need when a step misbehaves.

#StepPerformed byNotes
1InstantiatecreateBeanInstance: constructor or @Bean methodconstructor arguments resolved first; dependencies fully initialised already
2PopulateAutowiredAnnotationBeanPostProcessor@Autowired, @Value fields and setters
3Aware (core)invokeAwareMethodsBeanNameAware, BeanClassLoaderAware, BeanFactoryAware
4Aware (context) + BPP beforeApplicationContextAwareProcessor and your own BPPsEnvironmentAware, ApplicationEventPublisherAware, ApplicationContextAware, then postProcessBeforeInitialization
5@PostConstructCommonAnnotationBeanPostProcessorSpring re-registers its own annotation processors at the end of the BPP chain, so this runs after ordinary custom BPPs
6afterPropertiesSet()InitializingBeanties your class to Spring
7custom init method@Bean(initMethod = "init")for classes you do not own
8BPP aftere.g. AnnotationAwareAspectJAutoProxyCreatormay return a proxy; @Async, @Scheduled and @Repository translation also hook here
9@PreDestroyCommonAnnotationBeanPostProcessorat close(), singletons in reverse dependency order
10DisposableBean.destroy()DisposableBeanAdapter
11destroy-method@Bean(destroyMethod = ...)default is inferred: a public no-arg close() or shutdown() is called automatically
All the callbacks on one bean
public class ConnectionPool implements BeanNameAware, InitializingBean, DisposableBean { private static final Logger log = LoggerFactory.getLogger(ConnectionPool.class); private String name; public ConnectionPool() { log.info("1 constructor"); } @Autowired void setConfig(PoolConfig c) { log.info("2 populate (setter)"); } @Override public void setBeanName(String name) { this.name = name; log.info("3 BeanNameAware {}", name); } @PostConstruct void warmUp() { log.info("5 @PostConstruct"); } @Override public void afterPropertiesSet() { log.info("6 afterPropertiesSet"); } void customInit() { log.info("7 init-method"); } @PreDestroy void drain() { log.info("9 @PreDestroy"); } @Override public void destroy() { log.info("10 DisposableBean.destroy"); } void customClose() { log.info("11 destroy-method"); } } @Bean(initMethod = "customInit", destroyMethod = "customClose") ConnectionPool connectionPool() { return new ConnectionPool(); }
MechanismDefined onCouples to Spring?Prefer when
@PostConstruct / @PreDestroyyour classno (jakarta.annotation, a standard annotation)default choice for your own classes
InitializingBean / DisposableBeanyour classyesframework code that must not depend on annotation processing
@Bean(initMethod/destroyMethod)the @Bean declarationnothird-party classes you cannot edit
⚠ Production gotchas:
  • Importing the wrong annotation. Boot 3 uses jakarta.annotation.PostConstruct; javax.annotation.PostConstruct compiles only if an old jar is present and is then silently ignored. Symptom: init code never runs.
  • @Transactional or @Async on a @PostConstruct method does nothing. The callback runs on the raw object during step 5, before the proxy exists (step 8). Run startup work from an ApplicationReadyEvent listener or ApplicationRunner instead, where proxies are in place.
  • Long @PostConstruct blocks startup. The context does not finish refreshing, so readiness never turns green, and on Kubernetes the pod is killed. Move slow warm-ups behind an ApplicationRunner or a background task with readiness gating.
  • Inferred destroy method surprise. A @Bean returning an object with a public close() gets it called at shutdown even if you did not ask. To prevent that on a shared resource: @Bean(destroyMethod = "").
  • Exception in init. BeanCreationException: Error creating bean with name 'x': Invocation of init method failed: the root cause is under Caused by. The whole context fails to start by design.
06
Chapter Six · Extension Points

BeanFactoryPostProcessor vs BeanPostProcessor

Spring's own features are implemented with the same two extension points you can use. Understanding which phase each belongs to explains most "why was my bean not proxied / not processed" questions.

Two kinds of post-processors for two different phases
DEFINITION PHASE: no application bean exists yet BeanFactoryPostProcessoredit BeanDefinition: lazy, scope, property values BeanDefinitionRegistryPostProcessoradd or remove definitions (ConfigurationClassPostProcessor) declare such @Bean methods static INSTANCE PHASE: runs for every bean created BeanPostProcessorwrap or replace each bean (proxies, @Autowired) SmartInstantiationAware BPPpredict type, early references for cycles BPP beans are created before ordinary beans
BeanFactoryPostProcessorBeanPostProcessor
Operates onBeanDefinition metadatabean instances
Runsonce, before any ordinary bean is createdfor every bean, around initialisation
Typical useresolve ${placeholders}, change scope/lazy, register definitionsinject dependencies, run @PostConstruct, create proxies, register listeners
Spring examplesConfigurationClassPostProcessor, PropertySourcesPlaceholderConfigurerAutowiredAnnotationBeanPostProcessor, CommonAnnotationBeanPostProcessor, AnnotationAwareAspectJAutoProxyCreator, ScheduledAnnotationBeanPostProcessor
A BeanFactoryPostProcessor: edit recipes before cooking
@Component public class LazyClientsPostProcessor implements BeanFactoryPostProcessor { @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) { for (String name : bf.getBeanDefinitionNames()) { BeanDefinition def = bf.getBeanDefinition(name); String type = def.getBeanClassName(); if (type != null && type.endsWith("Client")) { def.setLazyInit(true); // do not connect at startup } } } }
A BeanPostProcessor: observe and wrap every bean
@Component public class SlowInitReporter implements BeanPostProcessor { private static final Logger log = LoggerFactory.getLogger(SlowInitReporter.class); private final Map<String, Long> started = new ConcurrentHashMap<>(); @Override public Object postProcessBeforeInitialization(Object bean, String name) { started.put(name, System.nanoTime()); return bean; // never return null here } @Override public Object postProcessAfterInitialization(Object bean, String name) { long ms = (System.nanoTime() - started.remove(name)) / 1_000_000; if (ms > 200) log.warn("bean '{}' took {} ms to initialise", name, ms); return bean; // return a proxy here to wrap the bean } } // Wrapping with a JDK proxy (what proxy creators do, in miniature) @Component class AuditProxyPostProcessor implements BeanPostProcessor { @Override public Object postProcessAfterInitialization(Object bean, String name) { if (!(bean instanceof Auditable)) return bean; return Proxy.newProxyInstance(bean.getClass().getClassLoader(), bean.getClass().getInterfaces(), (proxy, method, args) -> { log.info("calling {}.{}", name, method.getName()); return method.invoke(bean, args); // delegate to the raw bean }); } }
⚠ Production gotchas:
  • "Bean X is not eligible for getting processed by all BeanPostProcessors". INFO log Bean 'x' of type [...] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). The bean was created while post-processors were still being registered (usually because a BPP or a non-static @Bean BFPP depends on it), so it is not proxied: no @Transactional, no @Async. Fix: remove the dependency from the post-processor, or make the BPP/BFPP @Bean method static and keep it free of injected services.
  • A BPP returning a JDK proxy breaks by-class injection. BeanNotOfRequiredTypeException: Bean named 'orderService' is expected to be of type 'com.acme.OrderService' but was actually of type 'jdk.proxy2.$Proxy45'. Inject the interface, or proxy with CGLIB.
  • Returning null from postProcessBeforeInitialization stops later processors and the bean is lost. Always return the bean or its replacement.
  • Heavy dependencies in a BPP. Every dependency of a BPP is created early and misses other BPPs. Keep post-processors self-contained or use ObjectProvider for lazy access.
  • Order matters. Multiple BPPs run in PriorityOrdered, then Ordered, then unordered sequence. Implement Ordered if your wrapper must be inside or outside Spring's proxy creator.
Where a proxy is born: BeanPostProcessor.postProcessAfterInitialization
raw OrderServicenew + injected BPP A (Aware, context) BPP B (@PostConstruct) init callbacks run on raw AutoProxyCreatorBPP.after returns proxy OrderService$$SpringCGLIB what the cache stores Callers receive the proxy. @PostConstruct, afterPropertiesSet and init-method ran on the RAW object, so @Transactional / @Async on an init method is never applied.
07
Chapter Seven · Shutdown

Destruction, SmartLifecycle and Graceful Shutdown

At close(), AbstractApplicationContext.doClose() publishes ContextClosedEvent, stops Lifecycle beans, then destroys singletons so that dependents go before their dependencies. In Spring Boot the JVM shutdown hook is registered for you; in a plain AnnotationConfigApplicationContext you must call close() (or registerShutdownHook()), otherwise no callback ever runs.

What close() does, and what a hard kill skips
SIGTERMJVM shutdown hook ContextClosedEventpublished first Lifecycle stopSmartLifecycle, phases desc destroySingletonsdependents before dependencies per bean, in this order@PreDestroy, destroy(), destroy-method kill -9 / OOM kill / System.halt() skip every step: no callbacks run, in-flight work is lost Prototype beans are NOT in this list: the container never held a reference to them.
SmartLifecycle: start and stop things in a defined order

@PostConstruct runs while the context is still being built. Work that must start after everything is wired, and stop before its collaborators go (message consumers, pollers, schedulers), implements SmartLifecycle. DefaultLifecycleProcessor starts beans in ascending getPhase() order and stops them in descending order, waiting for each phase.

SmartLifecycle phases: start low-to-high, stop high-to-low
START: ascending phase phase 0metrics registry phase 100KafkaPoller high phaseweb server connector default phaseyour SmartLifecycle STOP: descending phase (reverse of start) 1st: highest phasestops first 2ndweb server drains requests 3rdKafkaPoller.stop(callback) lastmetrics flush Each phase waits for its beans (timeout-per-shutdown-phase, 30s by default), then the next phase begins.
A background consumer that starts last and stops first
@Component public class OrderPoller implements SmartLifecycle { private static final Logger log = LoggerFactory.getLogger(OrderPoller.class); private final OrderQueue queue; private volatile boolean running; private Thread worker; public OrderPoller(OrderQueue queue) { this.queue = queue; } @Override public void start() { running = true; worker = new Thread(() -> { while (running) { queue.pollAndHandle(Duration.ofMillis(500)); } log.info("poller loop exited cleanly"); }, "order-poller"); worker.start(); } @Override public void stop(Runnable callback) { // async-aware stop: signal when really done running = false; try { worker.join(5_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } callback.run(); // tells the processor this phase is finished } @Override public void stop() { stop(() -> { }); } @Override public boolean isRunning() { return running; } @Override public int getPhase() { return 1_000; } // after infrastructure, before the web server }
application.yml: graceful shutdown for a web application
server: shutdown: graceful # stop accepting, let in-flight requests finish spring: lifecycle: timeout-per-shutdown-phase: 30s # max wait per SmartLifecycle phase (default 30s)
HookRunsUse forNot for
@PostConstructstep 5, during bean creationcheap validation, derived fieldsslow I/O, anything needing proxies
ApplicationRunner / CommandLineRunnerafter context refresh, before ApplicationReadyEventone-shot startup taskslong-running loops (blocks startup)
SmartLifecycleafter singletons, ordered by phaseconsumers, pollers, servers with controlled stopsimple setup/teardown
@PreDestroyduring destroy, per beanclosing a resource the bean ownsdraining in-flight work across beans
ContextClosedEvent listenerat the start of shutdown, before lifecycle stopflip a "shutting down" flagresource teardown
⚠ Production gotchas:
  • No shutdown hook, no callbacks. In plain Spring, main returning without close() skips @PreDestroy. In tests, call close() (use try-with-resources).
  • Hard kills skip everything. kill -9, OOM killer, Kubernetes after terminationGracePeriodSeconds: set the grace period above timeout-per-shutdown-phase and make work idempotent.
  • Non-daemon threads keep the JVM alive. A pool created in @PostConstruct and never shut down stops the process from exiting after close(). Pair every pool with a destroy callback.
  • Phase collisions. Two lifecycle beans with the default phase stop in arbitrary order. Give dependent components explicit phases.
  • Blocking stop() exceeds the phase timeout. Log: Failed to shut down 1 bean with phase value ... within timeout of 30000ms. Implement stop(Runnable) and finish within budget.
  • Destroying a bean others still use. A @Bean with an inferred close() that wraps a shared client will close it for everyone; declare destroyMethod = "" if the owner is elsewhere.
08
Chapter Eight · Events

Application Events and Startup Sequence

The context doubles as an event bus. ApplicationEventPublisher.publishEvent(Object) delivers to every matching listener synchronously, on the publishing thread, unless you opt in to async. Events decouple "something happened" from "who cares", and are the cleanest way to break dependency cycles (see IoC & DI).

Publishing and listening
public record OrderPlaced(long orderId, String customerEmail) { } // any object is an event (Spring 4.2+) @Service public class OrderService { private final ApplicationEventPublisher events; private final OrderRepository repo; public OrderService(ApplicationEventPublisher events, OrderRepository repo) { this.events = events; this.repo = repo; } @Transactional public Order place(NewOrder cmd) { Order saved = repo.save(Order.from(cmd)); events.publishEvent(new OrderPlaced(saved.id(), cmd.email())); return saved; } } @Component class OrderPlacedListeners { @EventListener // runs in the caller's thread and transaction void audit(OrderPlaced e) { /* ... */ } @TransactionalEventListener // default phase AFTER_COMMIT void sendEmail(OrderPlaced e) { /* only if the order really committed */ } @Async @EventListener // separate thread; needs @EnableAsync void warmRecommendations(OrderPlaced e) { /* ... */ } }
What Boot publishes during startup
Spring Boot startup events, in publication order
ApplicationStartingEvent listeners registered, nothing else ApplicationEnvironmentPreparedEvent Environment ready, no context ApplicationContextInitializedEvent context created, no definitions ApplicationPreparedEvent definitions loaded, beans not yet ContextRefreshedEvent all singletons created ApplicationStartedEvent context refreshed, runners not run AvailabilityChangeEvent (liveness CORRECT) ApplicationReadyEvent CommandLineRunners done AvailabilityChangeEvent (readiness ACCEPTING_TRAFFIC)
ListenerFiresThread / transactionFailure effect
@EventListenerat publishEventpublisher thread, publisher transactionexception propagates to the publisher and can roll back its transaction
@EventListener + @Asyncat publishEventtask executor thread, no transactionlost unless an AsyncUncaughtExceptionHandler is set
@TransactionalEventListeneron transaction phase (default AFTER_COMMIT)publisher thread, transaction already completedcannot roll back the original transaction
ApplicationReadyEvent listenerafter runners, app accepts trafficmain threadlogged; app already started
⚠ Production gotchas:
  • Synchronous means coupled. A slow or failing listener slows or fails the publisher, and runs inside its transaction. If the listener must not affect the caller, make it async or transactional-after-commit.
  • @TransactionalEventListener without a transaction. If nothing is running a transaction when the event is published, the listener is silently not invoked unless fallbackExecution = true.
  • Database writes inside AFTER_COMMIT. The old transaction resources are still bound, so a plain @Transactional method there joins an already-committed transaction and its writes are never committed. Use @Transactional(propagation = Propagation.REQUIRES_NEW) on the listener's service call.
  • Listening to ContextRefreshedEvent for startup work. It fires for every refreshed context, including child contexts (and again on manual refresh()), so the code can run twice. Prefer ApplicationReadyEvent.
  • Events published before listeners exist. A bean publishing in its constructor or @PostConstruct can fire before @EventListener methods are registered; listeners registered later miss it. Early events are only multicast to listeners registered at that time.
09
Chapter Nine · Build It Yourself

The Lifecycle in Plain Java

This simulation re-implements the assembly line in plain Java so you can see the order. It has a BeanFactoryPostProcessor that runs before any bean exists, a BeanPostProcessor whose "after" hook replaces OrderService with a proxy subclass, reflection-driven @PostConstruct/@PreDestroy, BeanNameAware, InitializingBean/DisposableBean, custom init/destroy methods, destruction in reverse creation order, and a prototype scope demo. It uses no Spring classes; names are borrowed so the mapping is obvious.

MiniLifecycle.java: complete source
import java.lang.annotation.*; import java.lang.reflect.Method; import java.util.*; import java.util.function.*; public class MiniLifecycle { @Retention(RetentionPolicy.RUNTIME) @interface PostConstruct {} @Retention(RetentionPolicy.RUNTIME) @interface PreDestroy {} interface BeanNameAware { void setBeanName(String name); } interface InitializingBean { void afterPropertiesSet(); } interface DisposableBean { void destroy(); } interface BeanPostProcessor { default Object before(Object bean, String name) { return bean; } default Object after(Object bean, String name) { return bean; } } interface BeanFactoryPostProcessor { void postProcess(Map<String, Definition> defs); } enum Scope { SINGLETON, PROTOTYPE } static class Definition { final Supplier<Object> factory; Scope scope; final String initMethod, destroyMethod; Definition(Supplier<Object> f, Scope s, String init, String destroy) { factory = f; scope = s; initMethod = init; destroyMethod = destroy; } } static final List<String> LOG = new ArrayList<>(); static boolean verbose = true; static void log(String s) { if (!verbose) return; LOG.add(s); System.out.println(" " + LOG.size() + ". " + s); } static class Container { final Map<String, Definition> defs = new LinkedHashMap<>(); final List<BeanPostProcessor> bpps = new ArrayList<>(); final Map<String, Object> singletons = new LinkedHashMap<>(); // what callers receive (maybe a proxy) final Map<String, Object> raw = new LinkedHashMap<>(); // the original instances, in creation order (destroy callbacks run on these) final List<BeanFactoryPostProcessor> bfpps = new ArrayList<>(); void define(String n, Supplier<Object> f, Scope s, String init, String destroy) { defs.put(n, new Definition(f, s, init, destroy)); } void refresh() { bfpps.forEach(p -> p.postProcess(defs)); // 1. definitions can still change for (var e : defs.entrySet()) // 2. eager singletons if (e.getValue().scope == Scope.SINGLETON) getBean(e.getKey()); } Object getBean(String name) { Definition d = defs.get(name); if (d.scope == Scope.SINGLETON && singletons.containsKey(name)) return singletons.get(name); Object bean = create(name, d); if (d.scope == Scope.SINGLETON) singletons.put(name, bean); return bean; } private Object create(String name, Definition d) { Object bean = d.factory.get(); // instantiate (constructor runs) Object original = bean; if (d.scope == Scope.SINGLETON) raw.put(name, original); log("[" + name + "] constructor"); log("[" + name + "] populate properties (setters / @Autowired fields)"); if (bean instanceof BeanNameAware a) { a.setBeanName(name); log("[" + name + "] BeanNameAware.setBeanName"); } for (var p : bpps) bean = p.before(bean, name); for (Method m : bean.getClass().getDeclaredMethods()) if (m.isAnnotationPresent(PostConstruct.class)) { invoke(bean, m); log("[" + name + "] @PostConstruct " + m.getName()); } if (bean instanceof InitializingBean i) { i.afterPropertiesSet(); log("[" + name + "] afterPropertiesSet"); } if (d.initMethod != null) { invokeByName(bean, d.initMethod); log("[" + name + "] init-method " + d.initMethod); } for (var p : bpps) bean = p.after(bean, name); // proxies are born here return bean; } void close() { log("--- context.close() ---"); List<String> names = new ArrayList<>(raw.keySet()); Collections.reverse(names); // reverse creation order for (String n : names) { Object bean = raw.get(n); Definition d = defs.get(n); for (Method m : bean.getClass().getDeclaredMethods()) if (m.isAnnotationPresent(PreDestroy.class)) { invoke(bean, m); log("[" + n + "] @PreDestroy " + m.getName()); } if (bean instanceof DisposableBean db) { db.destroy(); log("[" + n + "] DisposableBean.destroy"); } if (d.destroyMethod != null) { invokeByName(bean, d.destroyMethod); log("[" + n + "] destroy-method " + d.destroyMethod); } } } static void invoke(Object b, Method m) { try { m.setAccessible(true); m.invoke(b); } catch (Exception e) { throw new RuntimeException(e); } } static void invokeByName(Object b, String n) { try { invoke(b, b.getClass().getDeclaredMethod(n)); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } } // ---------------- demo beans ---------------- static class DataSourcePool implements BeanNameAware, InitializingBean, DisposableBean { int size = 5; public void setBeanName(String n) {} @PostConstruct void warmUp() {} public void afterPropertiesSet() {} void customInit() {} @PreDestroy void closeIdle() {} public void destroy() {} void customDestroy() {} } static class OrderService { final DataSourcePool pool; OrderService(DataSourcePool p) { pool = p; } @PostConstruct void ready() {} @PreDestroy void bye() {} } // a trivial BPP that wraps "OrderService" in a stand-in proxy to show where proxies are born static class AuditProxy extends OrderService { final OrderService target; AuditProxy(OrderService t) { super(t.pool); target = t; } } static class Tracker { static int counter; final int id = ++counter; } static class ReportServiceStale { final Tracker tracker; ReportServiceStale(Tracker t) { tracker = t; } int trackerId() { return tracker.id; } } static class ReportServiceFresh { final Supplier<Tracker> provider; ReportServiceFresh(Supplier<Tracker> p) { provider = p; } int trackerId() { return provider.get().id; } } public static void main(String[] args) { System.out.println("== 1. lifecycle order (singletons, eager) =="); Container ctx = new Container(); ctx.bfpps.add(defs -> { defs.get("pool").scope = Scope.SINGLETON; log("BeanFactoryPostProcessor ran BEFORE any bean was created"); }); ctx.bpps.add(new BeanPostProcessor() { public Object before(Object b, String n) { log("[" + n + "] BPP.postProcessBeforeInitialization"); return b; } public Object after(Object b, String n) { log("[" + n + "] BPP.postProcessAfterInitialization"); return (b instanceof OrderService os && !(b instanceof AuditProxy)) ? new AuditProxy(os) : b; } }); ctx.define("pool", DataSourcePool::new, Scope.PROTOTYPE, "customInit", "customDestroy"); ctx.define("orderService", () -> new OrderService((DataSourcePool) ctx.getBean("pool")), Scope.SINGLETON, null, null); ctx.refresh(); System.out.println(" injected bean class: " + ctx.getBean("orderService").getClass().getSimpleName()); ctx.close(); System.out.println("\n== 2. prototype injected into a singleton =="); Container c2 = new Container(); c2.define("tracker", Tracker::new, Scope.PROTOTYPE, null, null); c2.define("stale", () -> new ReportServiceStale((Tracker) c2.getBean("tracker")), Scope.SINGLETON, null, null); c2.define("fresh", () -> new ReportServiceFresh(() -> (Tracker) c2.getBean("tracker")), Scope.SINGLETON, null, null); verbose = false; c2.refresh(); var stale = (ReportServiceStale) c2.getBean("stale"); var fresh = (ReportServiceFresh) c2.getBean("fresh"); System.out.println(" stale: " + stale.trackerId() + ", " + stale.trackerId() + ", " + stale.trackerId()); System.out.println(" fresh: " + fresh.trackerId() + ", " + fresh.trackerId() + ", " + fresh.trackerId()); } }
$ javac MiniLifecycle.java && java MiniLifecycle
== 1. lifecycle order (singletons, eager) == 1. BeanFactoryPostProcessor ran BEFORE any bean was created 2. [pool] constructor 3. [pool] populate properties (setters / @Autowired fields) 4. [pool] BeanNameAware.setBeanName 5. [pool] BPP.postProcessBeforeInitialization 6. [pool] @PostConstruct warmUp 7. [pool] afterPropertiesSet 8. [pool] init-method customInit 9. [pool] BPP.postProcessAfterInitialization 10. [orderService] constructor 11. [orderService] populate properties (setters / @Autowired fields) 12. [orderService] BPP.postProcessBeforeInitialization 13. [orderService] @PostConstruct ready 14. [orderService] BPP.postProcessAfterInitialization injected bean class: AuditProxy 15. --- context.close() --- 16. [orderService] @PreDestroy bye 17. [pool] @PreDestroy closeIdle 18. [pool] DisposableBean.destroy 19. [pool] destroy-method customDestroy == 2. prototype injected into a singleton == stale: 1, 1, 1 fresh: 2, 3, 4
Reading the output
  • Line 1: the factory post-processor ran before any constructor: definitions are edited before objects exist.
  • Lines 2 to 9: the exact order from chapter 5: constructor, populate, aware, BPP before, @PostConstruct, afterPropertiesSet, init-method, BPP after.
  • Lines 10 to 14: orderService is created after pool because its factory needs it: a dependency is fully initialised before its dependent.
  • injected bean class: AuditProxy: BPP-after returned a replacement, and that is what the container stored. The callbacks on lines 16 to 19 still ran on the raw instances, and in reverse order: orderService first, then pool.
  • stale: 1, 1, 1 vs fresh: 2, 3, 4: the prototype resolved once at injection time versus once per call through a provider.
Differences from real Spring
  • Real Spring runs @Autowired/@PostConstruct through BPPs too; here they are hard-coded steps. The visible order is the same.
  • Real Spring tracks disposable beans in DisposableBeanAdapters and handles dependent-bean ordering explicitly (registerDependentBean); the simulation reuses creation order.
  • Proxies in real Spring are CGLIB or JDK proxies created by AbstractAutoProxyCreator; see AOP & Proxies for a hand-written one.
10
Chapter Ten · Interview Angles

Ten Questions on Scopes and Lifecycle

What are the bean scopes, and what is the default?

singleton (default), prototype, and in web contexts request, session, application, websocket. You can register custom scopes (CustomScopeConfigurer).

Is a singleton bean thread-safe?

No. Spring guarantees one instance per container, nothing more. Keep it stateless/immutable or synchronise state. This is why per-request data lives in locals or request-scoped beans.

What happens when a prototype is injected into a singleton?

It is injected once at singleton creation, so the singleton keeps that one instance. Use ObjectProvider, @Lookup or a scoped proxy to get a fresh one per use.

How does a request-scoped bean work inside a singleton?

The singleton holds a CGLIB scoped proxy (@RequestScope sets proxyMode=TARGET_CLASS). Every method call resolves the target from the current thread's request attributes.

Give the lifecycle order of a bean.

Instantiate, populate, Aware callbacks, BPP before, @PostConstruct, afterPropertiesSet, init-method, BPP after (proxy), ready; on close @PreDestroy, destroy(), destroy-method.

BeanFactoryPostProcessor vs BeanPostProcessor?

BFPP edits definitions before any bean exists (placeholders, @Configuration parsing). BPP wraps every bean instance around initialisation (injection, @PostConstruct, proxies).

Why does @Transactional on a @PostConstruct method not work?

The callback runs on the raw object before postProcessAfterInitialization creates the proxy, so no advice applies. Run it from a listener on ApplicationReadyEvent or a runner.

Are destroy callbacks called for prototype beans?

No. The container creates and initialises them, then forgets them. You must release their resources yourself.

How do you start a consumer after everything is wired and stop it first on shutdown?

Implement SmartLifecycle with a phase; start is ascending phase, stop is descending, and stop(Runnable) lets you signal asynchronous completion. Combine with server.shutdown=graceful.

What is the difference between sync @EventListener and @TransactionalEventListener?

The first runs inside publishEvent in the publisher's transaction. The second is bound to a transaction phase (default AFTER_COMMIT) and is skipped if there is no transaction, unless fallbackExecution=true.

⚠ Follow-up traps interviewers use:
  • Trap: "@PostConstruct runs after the proxy is created." No, before; that is why proxy-based features do not apply there.
  • Trap: "singleton scope = GoF singleton." Per container and per definition, not per JVM or classloader.
  • Trap: "prototype beans get @PreDestroy." Only init callbacks run.
11
Chapter Eleven · Review

Summary & Cheat Sheet

ProblemReach forWhy
Shared mutable state in a servicelocals, immutable objects, or request scopesingleton = concurrent use
Fresh object per call from a singletonObjectProvider<T>lookup at call time
Per-request data used deep in the call chain@RequestScope bean (proxied)avoids threading context through every signature
Init that needs injected fields@PostConstructruns after populate
Init of a third-party class@Bean(initMethod = ...)no source access
Wrap or replace beans genericallyBeanPostProcessorafter-init hook returns the proxy
Change definitions before creationBeanFactoryPostProcessor (static @Bean)definition phase
Ordered start/stop of workersSmartLifecycle + phasedefined start and graceful stop
Decouple a side effect from a transaction@TransactionalEventListenerruns only after commit
∑ Scopes & lifecycle: what to remember
  • Default scope is singleton: shared and concurrent. Never keep per-request state in fields.
  • Injection happens once. Shorter-lived beans in a singleton need ObjectProvider, @Lookup or a scoped proxy.
  • Lifecycle order: constructor, populate, Aware, BPP before, @PostConstruct, afterPropertiesSet, init-method, BPP after (proxy), ready; @PreDestroy, destroy, destroy-method.
  • Proxies are made in BPP-after, so init callbacks see the raw object.
  • BFPP edits recipes; BPP wraps dishes. Keep both free of heavy dependencies, and make BFPP @Beans static.
  • Prototypes are never destroyed by Spring. Singletons are destroyed in reverse dependency order, only if the context is closed gracefully.
  • Use SmartLifecycle and graceful shutdown for anything that must drain work before the JVM exits.
  • Events are synchronous by default. Use after-commit listeners for side effects that must follow a successful transaction.
Related pages