The full picture
- Startup hook ladder: constructor (wiring only) → @PostConstruct (cheap self-setup) → ContextRefreshedEvent (context assembled) → ApplicationReadyEvent (Boot: ready to serve — warm caches, start consumers here) → SmartLifecycle.start() (when you need ordered phases and graceful stop symmetry).
- Shutdown mirror: ContextClosedEvent → SmartLifecycle.stop(phases in reverse) → @PreDestroy → destroy methods. Graceful shutdown = stop intake first (web server pauses, listeners stop), then drain, then close pools — phases exist exactly for this ordering.
- Domain events:
publisher.publishEvent(new OrderPlaced(id))decouples modules in a monolith;@TransactionalEventListener(phase = AFTER_COMMIT)prevents the classic 'email sent but transaction rolled back' bug. - Sync-by-default matters: listeners run on the caller's thread inside its transaction unless you opt out — both a feature (consistency) and a latency trap.
@Service
class CheckoutService {
@Transactional
public void checkout(Cart cart) {
var order = repo.save(Order.from(cart));
events.publishEvent(new OrderPlaced(order.id())); // buffered…
}
}
@Component
class NotificationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderPlaced e) { mailer.confirm(e.orderId()); } // …fires only on commit
}