Bean Lifecycle & Application Events¶
You have seen pieces of the lifecycle already: definitions registered by scanning, constructors called with dependencies, proxies swapped in by post-processors. This lesson assembles the full sequence — observed, not just described — and then covers application events, Spring's built-in way to decouple components inside one process.
The lifecycle, observed¶
This bean implements every common lifecycle hook and prints a line from each:
@Component
class LifecycleDemo implements BeanNameAware, InitializingBean, DisposableBean, SmartLifecycle {
private volatile boolean running;
LifecycleDemo() { System.out.println("1 constructor"); }
@Override public void setBeanName(String name) { System.out.println("2 setBeanName(" + name + ")"); }
@PostConstruct void postConstruct() { System.out.println("3 @PostConstruct"); }
@Override public void afterPropertiesSet() { System.out.println("4 afterPropertiesSet"); }
@Override public void start() { running = true; System.out.println("5 SmartLifecycle.start"); }
@EventListener(ApplicationReadyEvent.class) void ready() { System.out.println("6 ApplicationReadyEvent"); }
@EventListener(ContextClosedEvent.class) void closed() { System.out.println("7 ContextClosedEvent"); }
@Override public void stop() { running = false; System.out.println("8 SmartLifecycle.stop"); }
@PreDestroy void preDestroy() { System.out.println("9 @PreDestroy"); }
@Override public void destroy() { System.out.println("10 DisposableBean.destroy"); }
@Override public boolean isRunning() { return running; }
}
Running the Level 1 app with it (Boot 4.1), then stopping it with SIGTERM, printed:
1 constructor
2 setBeanName(lifecycleDemo)
3 @PostConstruct
4 afterPropertiesSet
5 SmartLifecycle.start
6 ApplicationReadyEvent
7 ContextClosedEvent
8 SmartLifecycle.stop
9 @PreDestroy
10 DisposableBean.destroy
Nobody implements all of these in real code. In practice:
- Use constructor injection for dependencies and
@PostConstructfor simple initialization that needs them. - Use
SmartLifecyclefor components that must start after the whole context is ready and stop in a controlled order (message listeners, schedulers, connection pools).getPhase()orders them; higher phases start later and stop earlier. - Use
ApplicationReadyEventfor "the app is fully up" work, such as warming a cache. - Use
@PreDestroyto release resources.DisposableBeandoes the same thing with a Spring interface in your class.
How It Actually Works¶
For each singleton, AbstractAutowireCapableBeanFactory.doCreateBean runs:
- Instantiate — choose the constructor and resolve its arguments (dependencies are created first, recursively).
- Populate — inject fields and setters (
@Autowired,@Value), via theAutowiredAnnotationBeanPostProcessor. - Aware callbacks —
BeanNameAware,BeanFactoryAware,ApplicationContextAware, … BeanPostProcessor.postProcessBeforeInitializationfor every registered post-processor.@PostConstructis actually invoked here, byCommonAnnotationBeanPostProcessor— which is why it runs beforeafterPropertiesSet.- Init methods —
InitializingBean.afterPropertiesSet, then any custom@Bean(initMethod = …). BeanPostProcessor.postProcessAfterInitialization— the step where the AOP auto-proxy creator may replace the bean with a proxy (@Transactional,@Async,@Cacheable, aspects). From here on, the container hands out the proxy.- Register for destruction if the bean has destroy callbacks.
After all singletons exist, the context's finishRefresh starts SmartLifecycle beans
(phase order) — that is why step 5 in the output comes after every @PostConstruct in the
application — and Boot then starts the web server and publishes ApplicationReadyEvent.
Shutdown reverses it. A SIGTERM triggers Boot's shutdown hook, which closes the context:
publish ContextClosedEvent, stop SmartLifecycle beans (reverse phase; with
server.shutdown: graceful, the web server's lifecycle waits for in-flight requests
here), then destroy singletons in reverse dependency order — @PreDestroy (again via
CommonAnnotationBeanPostProcessor), then DisposableBean.destroy.
Two practical corollaries: code in a constructor or @PostConstruct runs before
proxies exist, so calling your own @Transactional method there gets no transaction; and
BeanPostProcessors themselves are created very early, so a post-processor that depends
on ordinary beans forces those beans to be created early, without being post-processed
by later processors. Boot logs a warning ("is not eligible for getting processed by all
BeanPostProcessors") when this happens.
Application events¶
Events let one component announce that something happened without knowing who cares:
public record BookRetitled(long bookId, String oldTitle, String newTitle) { }
@Service
class CatalogService {
private final ApplicationEventPublisher events;
// ...
@Transactional
public BookView retitle(long id, String newTitle) {
Book book = books.findById(id).orElseThrow();
String old = book.getTitle();
book.setTitle(newTitle);
events.publishEvent(new BookRetitled(id, old, newTitle));
return BookView.from(book);
}
}
@Component
class SearchIndexUpdater {
@EventListener
void on(BookRetitled e) { /* reindex */ }
}
Key behaviors:
- Synchronous by default.
publishEventcalls each listener on the same thread, before returning. An exception in a listener propagates to the publisher and — inside a transaction — rolls it back. @TransactionalEventListenerdefers the listener until the transaction reaches a phase,AFTER_COMMITby default. The listener then sees committed data and cannot roll the publisher back. If no transaction is active, the event is dropped unlessfallbackExecution = true.- Add
@Asyncto a listener to run it on another thread. - Events are in-memory only. If the process dies after commit but before an
AFTER_COMMITlistener finishes, the work is lost. For must-happen side effects, use the outbox (lesson 06); Spring Modulith's event publication registry (Level 4) persists events for you.
Worked example: cache invalidation via events¶
Instead of CatalogService knowing about caches, search indexes, and audit logs:
@Component
class CatalogCacheInvalidator {
private final CacheManager caches;
CatalogCacheInvalidator(CacheManager caches) { this.caches = caches; }
@TransactionalEventListener
void on(BookRetitled e) {
Cache cache = caches.getCache("books");
if (cache != null) cache.evict(e.bookId());
}
}
The eviction happens only after the new title is committed — avoiding the "cache refilled with the old value before commit" race from lesson 04 — and the catalog service no longer depends on the cache.
Common mistakes¶
- Heavy work in constructors or
@PostConstruct(network calls, big loads), making startup slow and failures confusing. UseApplicationReadyEventor aSmartLifecycle. - Calling proxied methods during initialization.
- Expecting
@EventListenerto be asynchronous or transactional. It is neither by default. - Using events to hide a required call. If the operation must fail when the side effect fails, call it directly.
- Using
@TransactionalEventListeneroutside a transaction and wondering why it never fires.
Exercise¶
- Add
LifecycleDemoto your app, run it, and stop it with Ctrl+C. Compare your output with the listing above. Then add@Transactionalto one of its methods and printgetClass()in@PostConstructand in theApplicationReadyEventlistener — explain the difference in terms of step 6. - Implement
BookRetitledwith two listeners: a plain@EventListenerthat throws, and show that the retitle is rolled back; then change it to@TransactionalEventListenerand show that it is not. - Implement the cache invalidator and test that a retitle evicts the cached entry.
- Write a
SmartLifecycle"consumer" withgetPhase()and show, with logs, that it stops before your@PreDestroymethods run.