A Lighter‑Than‑MQ Asynchronous Solution: Spring’s Hidden Event‑Driven Feature
The article explains how Spring’s built‑in ApplicationEvent and @EventListener mechanism provides a lightweight, zero‑dependency alternative to external message queues for decoupling logic within the same JVM, covering core components, implementation styles, async execution, transactional listeners, pitfalls, performance comparison, and production best practices.
Problem
Monolithic createOrder logic mixes core order creation with many side‑effects (SMS, points, logging, notification, risk check). Adding new requirements forces the method to grow, and a failure in any dependency (e.g., riskService) can break the whole flow.
Spring event mechanism
Spring provides a lightweight publish‑subscribe model based on ApplicationEvent and @EventListener. The four core roles are:
Event – data carrier (subclass of ApplicationEvent; after Spring 4.2 any object is wrapped into PayloadApplicationEvent).
Publisher – ApplicationEventPublisher (usually injected as ApplicationContext).
Multicaster – ApplicationEventMulticaster, default implementation SimpleApplicationEventMulticaster, schedules dispatch to matching listeners.
Listener – implements ApplicationListener or a method annotated with @EventListener.
Define and publish an immutable event
public class OrderCreatedEvent extends ApplicationEvent {
private final String orderId;
private final Long userId;
private final BigDecimal totalAmount;
public OrderCreatedEvent(Object source, String orderId, Long userId, BigDecimal totalAmount) {
super(source);
this.orderId = orderId;
this.userId = userId;
this.totalAmount = totalAmount;
}
public String getOrderId() { return orderId; }
public Long getUserId() { return userId; }
public BigDecimal getTotalAmount() { return totalAmount; }
}Publish the event after the core DB operation:
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Transactional
public Order createOrder(CreateOrderRequest req) {
Order order = buildOrder(req);
orderDao.save(order);
// core logic ends here
eventPublisher.publishEvent(
new OrderCreatedEvent(this, order.getId(), order.getUserId(), order.getTotalAmount()));
return order;
}
}Listener implementations
Interface‑based listener (single event type)
@Component
public class SmsNotifyListener implements ApplicationListener<OrderCreatedEvent> {
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
smsService.send(event.getUserId(), "Order " + event.getOrderId() + " submitted");
}
}Limitation: one listener can handle only a single event type.
Annotation‑based listener (multiple events, conditions, return‑value publishing)
@Component
public class OrderEventHandler {
@EventListener
public void handleSms(OrderCreatedEvent event) {
smsService.send(event.getUserId(), "Order submitted");
}
@EventListener({OrderCreatedEvent.class, OrderPaidEvent.class})
public void handleOrderStateChange(ApplicationEvent event) {
auditService.log(event);
}
@EventListener(condition = "#event.totalAmount > 1000")
public void handleHighValueOrder(OrderCreatedEvent event) {
vipService.notifyHighValue(event.getOrderId());
}
@EventListener
public InventoryUpdateEvent handleOrder(OrderCreatedEvent event) {
return new InventoryUpdateEvent(this, event.getOrderId());
}
}Additional attributes: condition (SpEL filter), @Order (execution order), and automatic publishing of a returned event.
Synchronous vs asynchronous execution
By default listeners run synchronously; the publishing thread blocks until all listeners finish. This is simple and transaction‑friendly but a slow listener blocks the caller.
Enable async support:
@SpringBootApplication
@EnableAsync
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}Configure a dedicated thread pool:
@Configuration
public class AsyncConfig {
@Bean("eventExecutor")
public Executor eventTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("event-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}Mark a listener with @Async and the executor name:
@Component
public class SmsNotifyListener {
@Async("eventExecutor")
@EventListener
public void handleSms(OrderCreatedEvent event) {
smsService.send(event.getUserId(), "Order submitted");
}
}After adding @Async, publishEvent returns immediately while the listener runs in the pool.
Two limitations:
Exceptions are swallowed by the thread pool; listeners must catch them.
Listeners that return a new event cannot be async – manual publishing is required.
Transactional event listeners
When an event is published inside a transaction, a plain @EventListener may run before the transaction commits, leading to cache‑miss or data inconsistency if the transaction later rolls back.
@Component
public class OrderCacheListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void cleanCache(OrderUpdatedEvent event) {
redisTemplate.delete("order:" + event.getOrderId());
}
}Supported phases (default AFTER_COMMIT): AFTER_COMMIT – after successful commit. AFTER_ROLLBACK – after rollback. AFTER_COMPLETION – after commit or rollback. BEFORE_COMMIT – just before commit.
If the publishing method has no active transaction, the event is dropped. Setting fallbackExecution = true forces immediate execution.
Internal dispatch flow
During container refresh Spring performs two steps:
Initialise the ApplicationEventMulticaster (default SimpleApplicationEventMulticaster).
Register all listeners (both programmatic and beans implementing ApplicationListener).
protected void initApplicationEventMulticaster() {
if (beanFactory.containsLocalBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME)) {
this.applicationEventMulticaster = beanFactory.getBean(APPLICATION_EVENT_MULTICASTER_BEAN_NAME, ApplicationEventMulticaster.class);
} else {
this.applicationEventMulticaster = new SimpleApplicationEventMulticaster(beanFactory);
}
}When publishEvent is called, non‑ ApplicationEvent objects are wrapped into PayloadApplicationEvent and delegated to the multicaster:
protected void publishEvent(Object event, @Nullable ResolvableType eventType) {
ApplicationEvent appEvent = (event instanceof ApplicationEvent) ? (ApplicationEvent) event
: new PayloadApplicationEvent(this, event, eventType);
getApplicationEventMulticaster().multicastEvent(appEvent, eventType);
if (this.parent != null) { this.parent.publishEvent(event); }
}The multicaster obtains matching listeners (cached after first lookup) and invokes them either synchronously or via the configured TaskExecutor:
public void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType) {
ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
Executor executor = getTaskExecutor();
for (ApplicationListener<?> listener : getApplicationListeners(event, type)) {
if (executor != null) {
executor.execute(() -> invokeListener(listener, event));
} else {
invokeListener(listener, event);
}
}
}Call chain:
publishEvent()
└─ multicastEvent()
└─ getApplicationListeners() (cached)
└─ invokeListener()
└─ doInvokeListener()
└─ listener.onApplicationEvent()Common pitfalls
Mutable event objects : modifying the event inside a listener contaminates subsequent listeners. Keep events immutable (all fields final, no setters).
Missing @EnableAsync : @Async on a listener does nothing without enabling async support, leading to unexpected synchronous execution.
Event loops : publishing a new event from a listener that triggers the original listener creates a stack‑overflow. Design event flows to avoid cycles.
@TransactionalEventListener without a transaction : the event is dropped unless fallbackExecution = true is set.
Async listeners cannot return a new event : the return‑value publishing feature is disabled when @Async is present.
Spring built‑in events
Key container‑lifecycle events: ContextRefreshedEvent – triggered after ApplicationContext initialization or refresh; typical use: cache warm‑up. ContextStartedEvent – triggered by context.start(); typical use: start scheduled tasks. ContextStoppedEvent – triggered by context.stop(); typical use: stop scheduled tasks. ContextClosedEvent – triggered on container shutdown; typical use: graceful shutdown. RequestHandledEvent – triggered after an HTTP request is processed; typical use: request monitoring.
Example of cache pre‑load using ContextRefreshedEvent:
@Component
public class CachePreloadListener {
@EventListener(ContextRefreshedEvent.class)
public void preloadCache(ContextRefreshedEvent event) {
if (event.getApplicationContext().getParent() != null) {
return; // skip child context
}
CompletableFuture.runAsync(() -> {
provinceService.loadAll();
hotProductService.preload();
log.info("Cache pre‑load completed");
});
}
}In Spring Boot the equivalent is ApplicationReadyEvent, which fires once after the application is fully started.
Sync vs async illustration
// Synchronous publishing – the thread blocks until all listeners finish
eventPublisher.publishEvent(new OrderCreatedEvent(...));
log.info("Event processing completed");If a listener is slow, the caller is blocked.
Transactional event listener pitfalls
Example of cache deletion before commit:
@Transactional
public void updateOrder(...) {
// update DB
eventPublisher.publishEvent(new OrderUpdatedEvent(this, orderId));
// listener would delete cache here – may be wrong if transaction rolls back
}Using @TransactionalEventListener(phase = AFTER_COMMIT) ensures the cache is cleared only after a successful commit.
Two hidden traps:
If there is no active transaction, the event is silently dropped.
In AFTER_COMMIT phase a new transaction is not started automatically; Propagation.REQUIRES_NEW must be specified if needed.
Spring Event vs MQ comparison
Decision factors:
Scenario – Spring Event for same‑JVM module decoupling; MQ for cross‑service or cross‑system communication.
Reliability – Spring Event loses events on process crash; MQ provides persistence and retry.
Latency – in‑memory dispatch ≈ 0 ms; MQ adds network latency.
Development cost – Spring Event requires no extra dependency; MQ needs middleware deployment and learning.
Data consistency – Spring Event works with local transactions; MQ often requires distributed transaction solutions.
Throughput – synchronous ~15 ms, async + batch ~2 ms; measured >90 k events/s on an 8‑CPU instance. MQ throughput limited by network and broker capacity.
Message bus support – not supported by Spring Event; supported by MQ.
Production best practices
Keep event objects lightweight – pass only IDs and minimal data; let listeners fetch full details if needed.
One listener should perform a single responsibility (e.g., SmsListener, PointsListener).
Async listeners must catch their own exceptions and report/alert appropriately.
Instrument listeners (e.g., with an @Aspect) to log execution time and trigger alerts on slow processing.
Name events with past‑tense verbs (e.g., OrderCreatedEvent) to convey that the fact has already occurred.
Conclusion
Refactoring a bulky createOrder method to publish a single OrderCreatedEvent and adding dedicated listeners decouples non‑core responsibilities without external infrastructure. The resulting code is cleaner, more maintainable, and new features can be introduced by adding listeners only.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
