Skip to main content

Spring Application Events: @EventListener, @TransactionalEventListener and Async Events

Spring events are blocking method calls, and that one fact explains the surprises: a listener that rolls the publisher back, an AFTER_COMMIT listener that silently never runs, and an async listener that sees a different world. Every claim is reproduced on Spring Boot 4.1 with a committed transcript, including a rollback that proves AFTER_COMMIT semantics.

An order is placed. Then an email should go out, a counter should go up and an audit row should be written. Put those three calls inside OrderService and it now knows about every consumer of an order, and a fourth consumer means editing it again. Spring’s events turn that around: OrderService announces “an order was placed” and neither knows nor cares who is listening. That is the pitch, and it is true, but three things about it surprise people. An event is an ordinary blocking method call, so a slow listener slows the publisher and a failing one fails it. A listener that is supposed to wait for the database commit can silently not run, or run and write somewhere the finished transaction no longer covers. And an @Async listener leaves the publisher’s transaction behind, which changes what it can see. Each of those is reproduced below in a small project that was compiled and run, and every console block is quoted from a transcript that a test or a script wrote. The project is the core-events module of a companion repository. There is no separate documentation folder: the deeper material sits in the collapsible “going deeper” sections beside the paragraph each one extends.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9 (both poms were published to Maven Central on 20 August 2026), on Java 25 (LTS, Temurin 25.0.4.1), Maven 3.9 and the H2 version Boot 4.1.1 manages. Spring Modulith 2.1.1, the newest GA release listed in maven-metadata.xml (2.2.0-M1 is a milestone), appears only in the last section and was inspected, not run.

An event is a method call with the caller hidden

The smallest version has three parts: a class that describes what happened, a place that publishes it, and a method that listens. The event is a plain record with no base class and no interface (OrderPlaced.java):
public record OrderPlaced(String id, int amount) {
}
The publisher is an ordinary service that asks Spring for an ApplicationEventPublisher (OrderService.java):
public void place(String id, int amount) {
    Trace.add("OrderService.place: publishing on %s", Trace.where());
    publisher.publishEvent(new OrderPlaced(id, amount));
    Trace.add("OrderService.place: publishEvent returned");
}
And the listener is one annotated method (AuditListener.java). The second method shows that a listener which does not need the event can name its type in the annotation instead:
@EventListener
void audit(OrderPlaced event) {
    Trace.add("AuditListener.audit: received %s on %s", event, Trace.where());
}
@EventListener(OrderPlaced.class)
void counted() {
    Trace.add("AuditListener.counted: no parameter, still called");
}
Calling place("A-1", 42) from the test’s own thread prints this (from 01-publish-is-a-method-call.txt):
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
OrderService.place publishing on main publishEvent Spring calls each listener AuditListener.audit on main AuditListener.counted on main place carries on after publishEvent returned One thread throughout. The publisher does not get control back until every listener has finished. That single fact explains most of what surprises people about events.
The picture and the transcript say the same thing: the four lines come out in call order, and both the publishing and the listening happen on main. publishEvent is a method that calls other methods. That is why a slow listener slows the publisher, why a listener that throws throws into the publisher, and, later, why a listener sees the publisher’s transaction. Everything else in this article follows from it.
Nothing about the event class is special. The record above extends nothing and implements nothing, and the listener received it. Older material tells you to extend ApplicationEvent; the run above did not need to.
Going deeper: three consequences of “same thread”

First, timing: the publisher waits. A listener that calls a slow service adds its whole duration to place. Second, failure: an exception in a listener travels up through publishEvent into the publisher, which the next section shows. Third, context: the listener sees whatever the thread carries, including the publisher’s transaction, which the transaction sections measure.

If none of those are acceptable for a particular listener, the answers are further down: run it after commit, or run it on another thread. Neither is free, and the sections on them say what each costs.

Going deeper

Order, and an event that returns an event

When several listeners want the same event, the order they run in is decided by @Order. One listener below is in a different bean and has the lowest number, and one method returns a value, which Spring publishes as a new event. The five methods (OrderingListeners.java, and the early one in EarlyListener.java):
@EventListener
@Order(1)
void first(OrderPlaced event) {
    Trace.add("first        (@Order(1))");
}

@EventListener
@Order(2)
void second(OrderPlaced event) {
    Trace.add("second       (@Order(2))");
    if (Modes.fails("second")) {
        throw new IllegalStateException("second listener failed");
    }
}

/** No @Order: sorts last. */
@EventListener
void unordered(OrderPlaced event) {
    Trace.add("unordered    (no @Order)");
}

/** A returned event is published straight away, from inside this listener's turn. */
@EventListener
@Order(0)
ReceiptIssued receipt(OrderPlaced event) {
    Trace.add("receipt      (@Order(0)) returns a ReceiptIssued");
    return new ReceiptIssued(event.id());
}

@EventListener
void onReceipt(ReceiptIssued event) {
    Trace.add("onReceipt    received %s", event);
}
The listener that returns a value is receipt. Its return type is a ReceiptIssued, and onReceipt listens for one. The order they ran in (from 02-order-and-chaining.txt):
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)
earliest @Order(-10) receipt @Order(0) onReceipt runs inside receipt’s turn first @Order(1) second @Order(2) unordered no @Order: last The returned event is published straight away, so its listener runs before the next @Order slot, not after all of them. A listener with no @Order sorts after every listener that has one.
The returned ReceiptIssued did not wait its turn. receipt finished, Spring published what it returned, onReceipt ran to completion, and only then did the loop move on to first. A returned event is therefore a nested publish, not a queued one. Now make the @Order(2) listener throw. The publisher gets the exception, and the listener after it never runs (from 02-order-and-chaining.txt):
--- 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
One failing listener stops the rest. unordered does not appear in the second block, and publishEvent threw. Listeners are not isolated from one another, so a listener that must not be affected by another’s failure needs to run on its own thread (see the section on async listeners) or catch its own exceptions.
Going deeper: why every listener in this project carries an @Order

The transaction transcripts later in this article put several listeners in the same class and the same phase. I first left them without @Order, and two of them, AFTER_ROLLBACK and AFTER_COMPLETION, printed in one order in one run and the opposite order in the next. Without @Order, two listeners that tie have no defined order between them, and I would guess, though I did not check, that what I saw follows the order in which the JVM lists the methods. Treat that as a reason to be explicit whenever the order matters, and in a transcript, whenever a diff should mean something.

Going deeper

Reacting to only some events

A listener can be told to run only when a Spring expression, evaluated against the event, is true. The expression is the condition attribute, and #event names the event parameter (ConditionalListeners.java):
@Component
public class ConditionalListeners {

    @EventListener(condition = "#event.amount >= 100")
    @Order(1)
    void large(OrderPlaced event) {
        Trace.add("large       (amount >= 100)");
    }

    @EventListener(condition = "#event.id.startsWith('VIP')")
    @Order(2)
    void vip(OrderPlaced event) {
        Trace.add("vip         (id starts with VIP)");
    }

    @EventListener(condition = "#event.amount >= 100 and #event.id.startsWith('VIP')")
    @Order(3)
    void both(OrderPlaced event) {
        Trace.add("both        (large and VIP)");
    }
}
Four events published against it (from 03-conditional-listeners.txt):
--- 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)
Each listener ran for exactly the events its expression accepts. The last event matched nothing, and that is not an error: an event nobody wants is simply dropped.
A typo in a condition fails when the event is published, not when the application starts. I spelled a property #event.amountt. The context started, and the first publishEvent threw (from 03-conditional-listeners.txt):
--- 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?
That is a SpelEvaluationException coming out of the publisher, so a misspelled condition looks like a bug in whichever service published. A test that publishes one event through the real context is what finds it before production does.

Going deeper

Generic events, and the listener that hears nothing

It is natural to write one generic event, Created<T>, and let listeners say which kind they want. The event and a listener for each kind (Created.java, GenericListeners.java):
public record Created<T>(T value) {
}
@EventListener
void createdOrder(Created<Order> event) {
    Trace.add("Created<Order> listener got a %s", event.value().getClass().getSimpleName());
}
Publish a Created<Order> and, by the way generics look on the page, the first listener should hear it. It does not. On the JVM the type argument is erased, so Spring cannot tell from the object what T is. Here it is (from 04-generic-events-and-erasure.txt):
--- publishing Created<Order> ---
Created<?> listener got a Order
publishEvent returned normally
The line that ran belongs to a third listener I added, one declared for Created<?>. Neither typed listener was called, and nothing was logged. That is the whole failure: silence. The fix is to let the event say what it carries, by implementing ResolvableTypeProvider (TypedCreated.java):
public record TypedCreated<T>(T value) implements ResolvableTypeProvider {

    @Override
    public ResolvableType getResolvableType() {
        return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(value));
    }
}
With that, each typed listener hears its own kind and only its own (from 04-generic-events-and-erasure.txt):
--- publishing TypedCreated<Order> ---
TypedCreated<Order> listener got a Order
publishEvent returned normally

--- publishing TypedCreated<Customer> ---
TypedCreated<Customer> listener got a Customer
publishEvent returned normally
A generic event with no type information reaches only wildcard listeners. Either give the event a ResolvableTypeProvider, or use a plain concrete class per kind of event (OrderCreated, CustomerCreated), which needs no help at all and is what I would reach for first.

Going deeper

Events and transactions: wait for the commit

Now put a database in the picture. OrderService.place inserts a row, publishes the event, and either finishes or throws. It is @Transactional, so the insert is committed if the method returns and undone if it throws (OrderService.java):
@Transactional
public void place(String id, int amount, boolean fail) {
    jdbc.sql("insert into orders(id, amount) values (?, ?)").params(id, amount).update();
    Trace.add("OrderService.place: row inserted, publishing");
    publisher.publishEvent(new OrderPlaced(id, amount));
    if (fail) {
        throw new IllegalStateException("payment declined");
    }
    Trace.add("OrderService.place: leaving the method, commit follows");
}
Suppose one of the listeners sends a confirmation email. If it runs at publish time, the email goes out for an order that the very next line may roll back. What is wanted is a listener that waits until the outcome is known. That is @TransactionalEventListener, and its phase says which outcome to wait for.
insert + publish plain @EventListener runs here BEFORE_COMMIT commit path only commit or rollback the decision AFTER_COMMIT only if it committed AFTER_ROLLBACK only if it rolled back AFTER_COMPLETION either way The same publisher call, two outcomes. Which phase listeners ran is the whole difference, and the transcript below shows it. The two extra columns in it say whether the row could be seen from a different database connection at that moment.
The listeners are five methods, one per phase, each of which prints where it ran and asks a second, separate connection whether the new row is visible (PhaseListeners.java). Two of them:
@TransactionalEventListener
@Order(2)
void afterCommit(OrderPlaced event) {
    note("AFTER_COMMIT", event);
    if (Modes.fails("afterCommit")) {
        throw new IllegalStateException("AFTER_COMMIT listener failed");
    }
}
Run the publisher twice, once so that it commits and once so that it throws (from 05-transaction-phases-commit-and-rollback.txt):
--- 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
ListenerOn commitOn rollbackRow visible to another connection
@EventListenerranranno, the transaction is still open
BEFORE_COMMITrandid not runno
AFTER_COMMITrandid not runyes
AFTER_ROLLBACKdid not runranno, and it never will be
AFTER_COMPLETIONranranyes after a commit, no after a rollback
This is the answer to the email problem. The plain @EventListener ran in both cases, so an email sent from it would have gone out for bad-1, an order that does not exist. AFTER_COMMIT ran only for ok-1, and by then another connection could see the row, which is what a listener that queries the database, or calls another service that does, needs. The last block of the transcript is the proof from the database itself: one row for ok-1, none for bad-1.
“After commit” is not “after the transaction has left the thread”. Look at the tx active column: it says true in AFTER_COMMIT, AFTER_ROLLBACK and AFTER_COMPLETION, even though the outcome is already decided. The transaction has ended but its resources are still attached to the thread. The next two sections are about what that means for a listener that touches the database.
Going deeper: when to use BEFORE_COMMIT

BEFORE_COMMIT is the one phase that runs while the transaction can still be affected. Its row was not yet visible to another connection, and, as the section on exceptions shows, a failure in it rolls the whole transaction back. That makes it the right place for a check that must hold at the moment of commit, and a poor place for anything that talks to the outside world, which cannot be taken back.

Reference for all five phases and their interaction with the transaction manager: Spring reference: transaction-bound events.

Going deeper

No transaction, no event

A transactional listener has nothing to wait for if the publisher is not in a transaction. Spring’s answer is to not call it. I added a method that inserts and publishes with no @Transactional on it (OrderService.java) and started the application with one extra listener switched on (FallbackListener.java):
@TransactionalEventListener(fallbackExecution = true)
@org.springframework.core.annotation.Order(1)
void afterCommitOrNow(OrderPlaced event) {
    Trace.add("AFTER_COMMIT or immediately            (fallbackExecution = true)");
}
The result is in 06-no-transaction-drops-the-event.txt:
OrderService.placeWithoutTransaction: row inserted (auto-commit), publishing
@EventListener                         tx active: false  row visible to other connections: true
AFTER_COMMIT or immediately            (fallbackExecution = true)
Six listeners were declared for this event (the five from the last section and the fallback one) and only two lines appear. The plain @EventListener ran, with tx active: false. The one with fallbackExecution = true ran immediately, because there was no transaction to wait for. The BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK and AFTER_COMPLETION listeners did not run at all, and nothing said so. Spring’s reference describes this as designed behaviour and names fallbackExecution as the switch.
This is how a working listener stops working. Move a call from a @Transactional service method to one without the annotation, or call a @Transactional method from inside its own class so that the proxy is bypassed (the other article below shows that case), and every @TransactionalEventListener stops firing with no error. If a listener should still run in that case, say fallbackExecution = true; if it should not, this is the behaviour you want.

Going deeper

Writing to the database from an AFTER_COMMIT listener

The natural next step is a listener that records something, an audit row, after the order is safely committed. Three ways to declare it, each in its own package with the same publisher. First, with nothing said about transactions (AuditPlain.java):
@TransactionalEventListener
void audit(OrderPlaced event) {
    jdbc.sql("insert into audit(id, note) values (?, 'written after commit')").param(event.id()).update();
    Trace.add("inside the listener, right after the insert: audit row visible to other connections: %s",
            visibility.count("audit", event.id()) == 1);
}
Second, with the write given a transaction of its own (AuditNewTx.java):
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
void audit(OrderPlaced event) {
    jdbc.sql("insert into audit(id, note) values (?, 'written in REQUIRES_NEW')").param(event.id()).update();
}
Third, with plain @Transactional on the listener (AuditRequired.java), which is the fix most people reach for. The three results (from 07-after-commit-writes.txt):
--- 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)
The third one never gets to run: the application does not start. Spring rejects a @TransactionalEventListener that is @Transactional with anything other than REQUIRES_NEW or NOT_SUPPORTED, and the message says so. That is the good outcome, an error at start-up with the answer in it. The second variant is the fix it points at, and it works. The first is the surprising one.
The plain write “worked”, and I would not rely on it. Inside the listener, right after the insert, another connection could not see the audit row. After place() had returned, it was there. That is consistent with the listener having run while the finished transaction’s connection was still attached to the thread, so that its insert went onto that connection and became visible only when the connection was released. 13-transaction-cleanup-calls.txt is the list of calls DataSourceTransactionManager makes when it cleans up, and one of them is Connection.setAutoCommit. JDBC’s Javadoc for that method says that changing the mode during a transaction commits it, which is my best explanation for why the row landed. I did not isolate that step further, and I only ran plain JDBC on H2. A different resource, JPA for one, may not behave the same way, so treat “it worked” as an accident of this setup and use REQUIRES_NEW.
Going deeper: what REQUIRES_NEW costs

REQUIRES_NEW is defined to start a second transaction and suspend the current one, which normally means a second connection from the pool. I did not measure pool use here. In this project the first transaction is already finished, so the two would not overlap, but if you run many publishers concurrently it is worth checking that the pool has room for the listeners’ connections as well as the publishers’.

Because the audit write is now in a transaction of its own, a failure in it cannot undo the order. That is usually what you want from an audit trail, and it also means an audit row can be missing for an order that exists. If that gap is unacceptable, the write belongs in the order’s own transaction, in a BEFORE_COMMIT or plain listener, or in an outbox table, as the last section describes.

Going deeper

Whose exception is it? Failures in listeners

Section 1 said a listener’s exception travels into the publisher. In a transaction that has a consequence: if the publisher is rolled back, so is its row. Three listeners were made to fail in turn, one per run, and each time the caller and the database were asked what happened (from 08-exceptions-and-transactions.txt):
--- 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
The failing listenerDoes the caller see it?Does the order survive?
plain @EventListeneryesno, rolled back
BEFORE_COMMITyesno, rolled back
AFTER_COMMITnoyes, already committed
The first two are the same mechanism: the exception escapes place, and the transaction interceptor rolls back because of it. AFTER_COMMIT is the opposite. The commit has happened, the caller was told nothing, and the only trace is a log entry. The last two lines of the transcript are that entry: it is written by TransactionSynchronizationUtils and it names afterCompletion, not afterCommit.
A failed AFTER_COMMIT listener is invisible to the code that published. The order is committed, the confirmation email did not go out, and place returned normally. Nothing retries it. Log entries are the only signal, so this is the phase for side effects you can afford to lose, or for ones you record somewhere else first. Which phase to pick is really the question of what a failure should mean: “the order must not exist without it” is a plain or BEFORE_COMMIT listener, and “nice to have once the order exists” is AFTER_COMMIT.

Going deeper

Async listeners, and what they leave behind

To stop a listener from slowing or failing the publisher, run it on another thread. That is @Async on the listener, plus @EnableAsync somewhere in the application (AsyncConfig.java, AsyncListeners.java):
@Configuration(proxyBeanMethods = false)
@EnableAsync
public class AsyncConfig {
}
@Async
@EventListener
void slow(OrderPlaced event) throws InterruptedException {
    gate.publishReturned.await(5, TimeUnit.SECONDS);
    Trace.add("@Async @EventListener: running on %s, after publishEvent returned", Trace.where());
    gate.slowDone.countDown();
}
That listener waits until the publisher has moved on, which it could only do if the publisher were not waiting for it. The application was started with virtual threads off and then on, and once more with @EnableAsync missing (from 09-async-listener-threads.txt):
--- 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
Synchronous place listener (blocks place) place continues @Async place place continues, without waiting listener on another thread the publisher waits the publisher does not
Two things are visible in that transcript. With @EnableAsync, the listener ran after publishEvent had returned, on task-1 when virtual threads were off and on a virtual thread when spring.threads.virtual.enabled=true. And without @EnableAsync, the annotation did nothing: the listener ran on main, synchronously, with no error and no warning.
A forgotten @EnableAsync is a silent downgrade to synchronous. The last block of the transcript is the whole symptom: the code is marked async and behaves as though it were not. If a listener is supposed to be off the publisher’s thread, assert that in a test, as this project does.
The second thing an async listener changes is what it can see. The publisher below inserts a row, publishes, then holds its transaction open until a plain async listener has looked. A second async listener, declared AFTER_COMMIT, looks after the commit (from 10-async-and-the-transaction.txt):
@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
The plain async listener is on a thread that has no transaction, and it could not see the row, because the publisher had not committed yet. Its answer depends entirely on timing: run it a moment later, after the publisher finished, and it would presumably find the row (I did not run that variant). The AFTER_COMMIT async listener is the dependable one, since it is not started until the commit has happened. The third thing is failure. An exception in an async listener cannot reach the publisher, because the publisher is no longer there (from 11-async-exceptions.txt):
publishEvent returned normally
logged: Unexpected exception occurred invoking async method: void com.ankurm.events.asyncdemo.AsyncListeners.boom(com.ankurm.events.asyncdemo.Exploding)
Failure moves to a log line. publishEvent returned normally and the exception was reported by Spring’s default uncaught-exception handler for async methods. That is the same silent outcome as a failed AFTER_COMMIT listener, with the same consequence: anything that must not be lost needs to be recorded before it is handed off.
Going deeper: virtual threads and the executor

The transcript shows the observable difference, which is the kind of thread the listener lands on. It says nothing about how the executor is configured beyond that, and I did not measure throughput. The async executors and virtual threads article covers the executors themselves.

Going deeper

Where this goes next: Spring Modulith

Put the last three sections together and a pattern appears: run the listener on another thread, after the commit, in a transaction of its own. Each piece was needed for a reason shown above: after the commit so a rollback cancels it, another thread so it cannot slow or fail the publisher, its own transaction because the finished one cannot be written to. Spring Modulith packages exactly that combination as one annotation. I read its annotations off the 2.1.1 jar by reflection (from 12-modulith-application-module-listener.txt):
@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={})
@ApplicationModuleListener is @Async, @Transactional(propagation = REQUIRES_NEW) and @TransactionalEventListener in its default AFTER_COMMIT phase. What the library adds on top, according to its reference, is an event publication registry that records each event before it is handed to a listener, so that an event whose listener failed can be found and re-submitted. That registry is the answer to the “invisible failure” described above.
What I did and did not do here. I confirmed what the annotation is made of, from the jar. I did not run Modulith, so nothing in this article claims how the registry behaves, only what its documentation says. If you have events whose loss you cannot accept, the annotation above is a strong hint that you have outgrown plain @TransactionalEventListener, and the reference below is where to start.

Going deeper

Should you use events here?

Not every call deserves an event. An event buys you a publisher that does not name its consumers, and it costs you a call whose target you cannot find by following the code. If exactly one collaborator will ever care, call it directly: it is shorter, the stack trace is honest and the transaction behaviour is the one you can see. Reach for an event when the set of consumers is open-ended or belongs to other modules, and then choose the mechanism by what a failure should mean. Plain @EventListener when the work is part of the same unit of work and its failure should undo it. AFTER_COMMIT when it should happen only for committed data and can be lost. @Async when the publisher must not wait, and never on its own for anything you cannot afford to lose, because its failures land in a log. An outbox table, or Modulith’s registry, when the event has to survive a crash between the commit and the listener.

None of that is visible in the code that publishes, which is the trade. Write the tests that publish through the real context and assert on the outcome, as the ones in this repository do.

Going deeper

  • Run everything yourself: the module README has the quick-start and an index of the thirteen transcripts; ./scripts/run-all.sh regenerates them

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.