Application Events
Decouple side effects from core logic by publishing events with ApplicationEventPublisher and handling them in @EventListener methods.
events.publishEvent(new SomethingHappened(...));
@EventListener
void on(SomethingHappened event) { ... }
@EventListener(condition = "#event.total > 1000")
@Async @EventListener
@EventListener(ApplicationReadyEvent.class)When an order is placed, several things should follow: an audit entry, a confirmation email, a loyalty points update. Calling each of those from OrderService ties the service to every feature that cares about orders. Application events invert that: the service announces what happened, and each feature listens for it.
Publishing and listening
An event is any object; a record named in the past tense works well. Inject ApplicationEventPublisher and call publishEvent. Any bean method annotated with @EventListener whose parameter type matches is called.
public record OrderPlaced(long orderId, long customerId, BigDecimal total) {}
events.publishEvent(new OrderPlaced(order.getId(), customerId, total));
@EventListener
void on(OrderPlaced event) { ... }Synchronous by default
This surprises people: listeners run on the publisher's thread, one after another, before publishEvent returns. That means:
- A slow listener makes the HTTP request slow.
- An exception in a listener propagates to the publisher, and if the publisher is
@Transactional, the whole order rolls back because the email template had a typo. - Listeners run inside the publisher's transaction, so they see uncommitted data and their writes commit or roll back with it.
Add @Async to a listener (with @EnableAsync) to run it on the task executor instead. For side effects that must only happen once the data is committed, such as emails, use @TransactionalEventListener, covered in the transactions stage. Without it, an async listener can send a confirmation for an order that then rolls back.
Shaping listeners
@Order sets the order of synchronous listeners for the same event. condition takes a SpEL expression such as "#event.total > 1000". A listener that returns a non-null value publishes it as a new event, which is neat but makes the flow harder to follow; prefer publishing explicitly.
Spring Boot publishes its own lifecycle events too. ApplicationReadyEvent fires once the application is started and ready to serve, which makes it the right place to warm a cache, rather than a constructor or @PostConstruct.
Limits
Events stay inside one JVM and live only in memory. If the process dies after the commit and before an async listener runs, that work is gone. For anything that must reliably reach another service, store it in an outbox table or send it through a message broker. Events are for decoupling code within one application, not for integration between applications.
Example
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
record OrderPlaced(long orderId, long customerId, BigDecimal total) {
}
class AsyncConfig {
}
public class OrderService {
private final ApplicationEventPublisher events;
public OrderService(ApplicationEventPublisher events) {
this.events = events;
}
public long place(long customerId, BigDecimal total) {
long orderId = 1001L;
events.publishEvent(new OrderPlaced(orderId, customerId, total));
return orderId;
}
}
class AuditListener {
private static final Logger log = LoggerFactory.getLogger(AuditListener.class);
void record(OrderPlaced event) {
log.info("Order {} placed by customer {}", event.orderId(), event.customerId());
}
}
class LoyaltyListener {
void award(OrderPlaced event) {
long points = event.total().longValue();
// add points for event.customerId()
}
}
When to use it
- An order service publishes OrderPlaced and separate listeners write the audit log and award loyalty points without the service knowing about either.
- A user service publishes AccountClosed so the billing feature can cancel subscriptions and the search feature can remove the profile.
- A catalogue API warms its country and currency caches in an ApplicationReadyEvent listener before the first request arrives.
More examples
Ordering and conditional listeners
Lower @Order values run first among synchronous listeners. The condition is evaluated before the call, so small orders never enter flagLargeOrder. SpEL compares a BigDecimal with a plain number by converting both to BigDecimal, so #event.total > 1000 works even though Java itself has no > for BigDecimal.
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
record OrderPlaced(long orderId, long customerId, BigDecimal total) {
}
@Component
public class FraudChecks {
private static final Logger log = LoggerFactory.getLogger(FraudChecks.class);
@Order(1)
@EventListener
void basicChecks(OrderPlaced event) {
log.debug("Basic checks for order {}", event.orderId());
}
@Order(2)
@EventListener(condition = "#event.total > 1000")
void flagLargeOrder(OrderPlaced event) {
log.info("Order {} over 1000 flagged for review", event.orderId());
}
}
Warming a cache when the application is ready
ApplicationReadyEvent arrives after the context is refreshed and runners have finished, so every bean, data source and cache is available. The listener runs before Spring Boot reports the application as ready to accept traffic, so on Kubernetes the readiness probe keeps requests away until the cache is warm. Keep it quick: tests that start the full application run it too, and a slow warm-up delays every one of them.
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class CacheWarmer {
private final CacheManager cacheManager;
public CacheWarmer(CacheManager cacheManager) {
this.cacheManager = cacheManager;
}
@EventListener(ApplicationReadyEvent.class)
public void warm() {
Cache countries = cacheManager.getCache("countries");
if (countries == null) {
return;
}
Map.of("GB", "United Kingdom", "IE", "Ireland", "FR", "France", "DE", "Germany")
.forEach(countries::putIfAbsent);
}
}
Asserting that an event was published
@RecordApplicationEvents captures every event published during each test, and ApplicationEvents.stream(type) filters them. Records compare by value, so the assertion reads naturally. Testing the publisher and each listener separately keeps both sides small.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig
@RecordApplicationEvents
class AccountServiceEventsTest {
record AccountClosed(long accountId) {
}
static class AccountService {
private final ApplicationEventPublisher events;
AccountService(ApplicationEventPublisher events) {
this.events = events;
}
void close(long accountId) {
events.publishEvent(new AccountClosed(accountId));
}
}
@Configuration
static class Config {
@Bean
AccountService accountService(ApplicationEventPublisher events) {
return new AccountService(events);
}
}
@Autowired
AccountService service;
@Autowired
ApplicationEvents events;
@Test
void publishesAccountClosed() {
service.close(7L);
assertThat(events.stream(AccountClosed.class))
.containsExactly(new AccountClosed(7L));
}
}
Discussion