Add core-events module: @EventListener, @TransactionalEventListener phases and async listeners on Boot 4.1

Thirteen captured transcripts: a listener is a blocking method call, ordering and chaining, SpEL conditions,
generic-event erasure, every transaction phase on commit and rollback, what an AFTER_COMMIT listener can
write, which exceptions reach the publisher, async listeners on platform and virtual threads, and the
annotations behind Spring Modulith's @ApplicationModuleListener.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
Claude
2026-09-24 07:08:47 +00:00
parent c418589251
commit d6b76c57db
72 changed files with 1658 additions and 0 deletions
@@ -0,0 +1,6 @@
# OrderService publishes an OrderPlaced; the listeners run before publishEvent returns, on the same thread
OrderService.place: publishing on main
AuditListener.audit: received OrderPlaced[id=A-1, amount=42] on main
AuditListener.counted: no parameter, still called
OrderService.place: publishEvent returned
@@ -0,0 +1,18 @@
# Listener order, an event returned from a listener, and a listener that throws
--- all listeners succeed ---
earliest (@Order(-10), a different bean)
receipt (@Order(0)) returns a ReceiptIssued
onReceipt received ReceiptIssued[orderId=A-1]
first (@Order(1))
second (@Order(2))
unordered (no @Order)
--- the @Order(2) listener throws ---
earliest (@Order(-10), a different bean)
receipt (@Order(0)) returns a ReceiptIssued
onReceipt received ReceiptIssued[orderId=A-2]
first (@Order(1))
second (@Order(2))
publishEvent threw: IllegalStateException: second listener failed
@@ -0,0 +1,21 @@
# @EventListener(condition = ...) evaluated against the event, and a condition that names a property that does not exist
--- OrderPlaced(VIP-1, 150) ---
large (amount >= 100)
vip (id starts with VIP)
both (large and VIP)
--- OrderPlaced(VIP-2, 20) ---
vip (id starts with VIP)
--- OrderPlaced(X-3, 500) ---
large (amount >= 100)
--- OrderPlaced(X-4, 1) ---
(no listener ran)
--- a condition that says #event.amountt ---
context started: true
publishEvent threw: SpelEvaluationException
root cause: EL1008E: Property or field 'amountt' cannot be found on object of type 'com.ankurm.events.badcondition.OrderPlaced' - maybe not public or not valid?
@@ -0,0 +1,18 @@
# Created<Order> and Created<Customer> published as plain payloads, then the same with ResolvableTypeProvider
--- publishing Created<Order> ---
Created<?> listener got a Order
publishEvent returned normally
--- publishing Created<Customer> ---
Created<?> listener got a Customer
publishEvent returned normally
--- publishing TypedCreated<Order> ---
TypedCreated<Order> listener got a Order
publishEvent returned normally
--- publishing TypedCreated<Customer> ---
TypedCreated<Customer> listener got a Customer
publishEvent returned normally
@@ -0,0 +1,21 @@
# The same listeners, one publisher call that commits and one that rolls back
--- place("ok-1", fail = false) ---
OrderService.place: row inserted, publishing
@EventListener tx active: true row visible to other connections: false
OrderService.place: leaving the method, commit follows
BEFORE_COMMIT tx active: true row visible to other connections: false
AFTER_COMMIT tx active: true row visible to other connections: true
AFTER_COMPLETION tx active: true row visible to other connections: true
--- place("bad-1", fail = true) ---
OrderService.place: row inserted, publishing
@EventListener tx active: true row visible to other connections: false
AFTER_ROLLBACK tx active: true row visible to other connections: false
AFTER_COMPLETION tx active: true row visible to other connections: false
caller saw: IllegalStateException: payment declined
--- rows afterwards ---
ok-1 rows : 1
bad-1 rows: 0
@@ -0,0 +1,5 @@
# Publishing outside any transaction: which listeners run
OrderService.placeWithoutTransaction: row inserted (auto-commit), publishing
@EventListener tx active: false row visible to other connections: true
AFTER_COMMIT or immediately (fallbackExecution = true)
@@ -0,0 +1,16 @@
# An AFTER_COMMIT listener that inserts a row: three ways to declare it
--- no transaction attribute on the listener ---
inside the listener, right after the insert: audit row visible to other connections: false
after place() returned, order rows: 1
after place() returned, audit rows: 1
--- @Transactional(propagation = REQUIRES_NEW) on the listener ---
order rows: 1
audit rows: 1
--- @Transactional (REQUIRED, the default) on the listener ---
context started: false
failure chain: BeanInitializationException -> IllegalStateException
root cause: @TransactionalEventListener method must not be annotated with @Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: void com.ankurm.events.txwrite.required.AuditRequired.audit(com.ankurm.events.txwrite.shared.OrderPlaced)
@@ -0,0 +1,16 @@
# Which listener's exception reaches the caller, and whether the order row survives
--- plain listener throws ---
caller saw: IllegalStateException: plain listener failed
order row survived: false
--- beforeCommit listener throws ---
caller saw: IllegalStateException: BEFORE_COMMIT listener failed
order row survived: false
--- afterCommit listener throws ---
caller saw: nothing
order row survived: true
logged: TransactionSynchronization.afterCompletion threw exception
logged: java.lang.IllegalStateException: AFTER_COMMIT listener failed
@@ -0,0 +1,15 @@
# @Async @EventListener: the publisher moves on first; which thread the listener gets
--- spring.threads.virtual.enabled=false ---
publishing on main
publishEvent returned, releasing the listener
@Async @EventListener: running on platform thread task-1, after publishEvent returned
--- spring.threads.virtual.enabled=true ---
publishing on main
publishEvent returned, releasing the listener
@Async @EventListener: running on a virtual thread, after publishEvent returned
--- the same listener in an application without @EnableAsync ---
@Async @EventListener: running on main
@@ -0,0 +1,5 @@
# Two async listeners for an event published inside a transaction
@Async @EventListener: transaction active on this thread: false; row visible: false
AsyncOrderService.place: leaving the method, commit follows
@Async @TransactionalEventListener(AFTER_COMMIT): on a virtual thread; row visible: true
@@ -0,0 +1,4 @@
# An @Async listener that throws: what the publisher sees, and where the exception goes
publishEvent returned normally
logged: Unexpected exception occurred invoking async method: void com.ankurm.events.asyncdemo.AsyncListeners.boom(com.ankurm.events.asyncdemo.Exploding)
@@ -0,0 +1,5 @@
# spring-modulith-events-api 2.1.1: the annotations that @ApplicationModuleListener carries (reflection)
@org.springframework.scheduling.annotation.Async("")
@org.springframework.transaction.annotation.Transactional(propagation=REQUIRES_NEW, rollbackForClassName={}, readOnly=false, transactionManager="", isolation=DEFAULT, timeoutString="", label={}, noRollbackFor={}, noRollbackForClassName={}, value="", timeout=-1, rollbackFor={})
@org.springframework.transaction.event.TransactionalEventListener(phase=AFTER_COMMIT, condition="", fallbackExecution=false, id="", value={}, classes={})
@@ -0,0 +1,13 @@
# DataSourceTransactionManager.doCleanupAfterCompletion in spring-jdbc 7.0.9: the calls it makes (javap -c)
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.isNewConnectionHolder:()Z
obtainDataSource:()Ljavax/sql/DataSource;
org/springframework/transaction/support/TransactionSynchronizationManager.unbindResource:(Ljava/lang/Object;)Ljava/lang/Object;
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.getConnectionHolder:()Lorg/springframework/jdbc/datasource/ConnectionHolder;
org/springframework/jdbc/datasource/ConnectionHolder.getConnection:()Ljava/sql/Connection;
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.isMustRestoreAutoCommit:()Z
java/sql/Connection.setAutoCommit:(Z)V
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.getPreviousIsolationLevel:()Ljava/lang/Integer;
org/springframework/jdbc/datasource/DataSourceTransactionManager$DataSourceTransactionObject.isReadOnly:()Z
isDefaultReadOnly:()Z
org/springframework/jdbc/datasource/DataSourceUtils.resetConnectionAfterTransaction:(Ljava/sql/Connection;Ljava/lang/Integer;Z)V