Spring's Hidden Async Alternative to MQ: Built-in Events for In-Process Decoupling
This article explains how Spring's built-in event mechanism provides a lightweight alternative to message queues for in-process asynchronous decoupling, covering synchronous vs asynchronous handling, transactional event listeners, thread pool configuration, lifecycle events, and when persistent solutions are needed.
When an order placement method accumulates SMS, loyalty points, logging, and other side effects, every requirement change forces modifications to the core flow. The author recommends first distinguishing two scenarios: decoupling modules within the same process can use Spring Event; tasks that must not be lost require persistence and failure recovery.
Spring Event Basics
An event object carries data, ApplicationEventPublisher publishes it, the multicaster finds matching listeners, and @EventListener handles it. The event can be a plain object — it does not need to extend ApplicationEvent.
The default multicaster invokes listeners synchronously: the publishing thread waits until all listeners finish, and an unhandled exception in a listener can interrupt the publishing flow. Simply extracting classes does not prevent the SMS call from slowing down order placement.
Making It Asynchronous
To run listeners asynchronously, enable @EnableAsync, make the listener a Spring bean, and annotate the handler method with @Async specifying a thread pool (e.g., eventExecutor).
Order Submission Then Notification
The core example uses JDK 17 and Spring Framework 6.2.12. Besides async configuration, Spring transaction management must be enabled ( @EnableTransactionManagement in plain Spring; verify it works in Spring Boot) and a ThreadPoolTaskExecutor named eventExecutor must be configured.
record OrderCreated(long id) {}
@Component
class OrderNotice {
@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderCreated event) {
System.out.println("已提交订单:" + event.id());
}
}Inside the active order transaction, after saving, call publisher.publishEvent(new OrderCreated(id)). The event should carry only the necessary ID or an immutable snapshot — avoid sharing mutable entities across threads. AFTER_COMMIT is the default phase; the listener does not run if the transaction rolls back. If publishing occurs without a transaction, the listener also does not run by default. Setting fallbackExecution=true allows execution without a transaction, but it does not recreate the "commit succeeded" guarantee.
Thread Changed — Failure Handling Still Needed
The thread pool must have bounded capacity and a rejection policy. Exceptions in async methods do not propagate back to the publisher; configure an AsyncUncaughtExceptionHandler. If task submission is rejected (queue full), a try-catch inside the listener method cannot catch it. @Order only affects the scheduling order of listeners, not the completion order of async tasks. The new thread does not inherit the original transaction boundary; any database writes need their own transaction demarcation.
Deleting cache after commit avoids some timing issues, but deletion can still fail. Deleting before commit risks concurrent reads repopulating stale values. Do not label all these cases as cache penetration.
Startup Events — Know the Trigger
The arrows in the diagram do not represent a fixed sequence for every Boot startup. Refresh, explicit start(), stop(), and close are distinct operations; in parent-child container hierarchies, events are not guaranteed to fire exactly twice.
Boot's ApplicationReadyEvent signals the application has reached the ready phase, which differs from ContextRefreshedEvent. Where to place warm-up logic depends on whether it must complete before traffic arrives.
When Persistent Solutions Are Required
Spring Event has no built-in persistence, consumption acknowledgment, or reliable redelivery. If the process crashes after transaction commit but before the async notification is sent, the notification is lost — adding @Async does not recover it.
For cross-service communication or tasks that cannot be lost, consider MQ, a persistent task table, or the outbox pattern, supplemented with delivery acknowledgment, retries, and idempotency. Using MQ alone does not automatically guarantee reliability.
Critical validations such as inventory, payment verification, or risk control that determine whether an order can be created must not be moved to the background. Start with non-critical notifications that allow remedial handling, build failure recording and remedial processes, then decide if a persistent pipeline is necessary.
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.
Java Architect Essentials
Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.
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.
