skip to content

Break down the semantics of @ApplicationModuleListener — what does each composed part mean, and what gotchas follow from async + AFTER_COMMIT + REQUIRES_NEW?

level: middleimportance: should knowfreq 40%

answer

  1. AFTER_COMMIT = sees committed data, skipped on rollback
  2. @Async needs @EnableAsync, off the caller thread
  3. REQUIRES_NEW = listener's own transaction
  4. can't roll back publisher; exceptions logged not thrown
  5. at-least-once → idempotent handlers

basics

~10 s

It stacks @TransactionalEventListener (fire after the publisher commits), @Async (run on another thread), and @Transactional(REQUIRES_NEW) (run in its own transaction). Gotchas: needs @EnableAsync, exceptions don't roll back the publisher, and delivery is eventually consistent.

solid answer

~40 s

@ApplicationModuleListener composes three behaviors. @TransactionalEventListener with the default AFTER_COMMIT phase means the method only runs once the publishing transaction has committed — so it sees committed data and never runs on a rollback. @Async offloads it to a task executor thread, so it doesn't block the publisher's request. @Transactional(propagation = REQUIRES_NEW) starts a brand-new transaction for the listener, decoupled from the (already-finished) publisher transaction. The main gotchas: you must enable async processing (@EnableAsync) or it silently runs synchronously/on the caller in some setups; the listener's exceptions can't roll back the publisher because that transaction already committed; and because it's async-after-commit, a crash before completion would drop the event unless the Event Publication Registry is on the classpath to persist and later replay it. Idempotency matters since replay implies at-least-once.

go deeper

for a junior

Know the three parts by name and that the listener runs after commit.

for a middle

Explain each composed annotation and the practical gotchas (no rollback of publisher, need @EnableAsync, idempotency).

for a senior

Connect the async-after-commit window to why a durable registry is required, and reason about replay/idempotency.

for a principal

Decide when eventual-consistency-via-events is appropriate vs. a synchronous consistent path, and set team conventions for idempotency keys.

## The three composed pieces `@ApplicationModuleListener` (package `org.springframework.modulith.events`) is defined roughly as: ```java @Async @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener public @interface ApplicationModuleListener {} ``` ### 1. `@TransactionalEventListener` (phase = AFTER_COMMIT, the default) A normal `@EventListener` fires *synchronously, inline*, as part of `publishEvent(...)` — inside the publisher's transaction. `@TransactionalEventListener` instead binds the callback to the surrounding transaction's lifecycle and, by default, only runs it **after that transaction successfully commits**. Consequences: - The listener observes **committed** state. - If the publisher **rolls back**, the event is *dropped* (listener never runs). - If there is **no active transaction** at publish time, by default the listener is **not invoked at all** (unless configured with `fallbackExecution = true`). This trips people up in tests that publish outside a transaction. ### 2. `@Async` Runs the listener on a thread from the configured `TaskExecutor` instead of the publisher's thread. This requires **`@EnableAsync`** somewhere in the context; without proper async configuration the intended off-thread behavior won't happen. Because it's on another thread, the publisher's HTTP response can return before the listener finishes. ### 3. `@Transactional(propagation = REQUIRES_NEW)` The listener gets its **own new transaction**. There's nothing to join anyway (the publisher already committed and we're on a different thread), so REQUIRES_NEW cleanly scopes the listener's DB work. If the listener writes to the database and throws, only the listener's transaction rolls back — the publisher's committed work is untouched. ## Gotchas that follow - **You cannot roll back the publisher from the listener.** The order is already saved. If the listener's work is critical and must be atomic with the publisher, an after-commit async event is the wrong tool — use a synchronous in-transaction path. - **Exceptions vanish silently by default.** An async listener's exception isn't propagated to the caller; it surfaces via the `AsyncUncaughtExceptionHandler` / logs. Without the Event Publication Registry, that failed event is simply lost. - **At-least-once, so make listeners idempotent.** With the registry enabled, a failed/incomplete publication can be **replayed** (e.g. on restart). Replays plus multi-consumer fan-out mean a listener may see the same event more than once — design the handler to be safe under repetition (natural keys, upserts, dedup). - **Ordering / concurrency.** Multiple `@ApplicationModuleListener`s for one event each get their **own** publication entry and run independently; don't assume ordering between them. - **Publishing after commit within a listener** creates chains — fine, but each hop is its own async/after-commit boundary. ## When the defaults aren't what you want If you genuinely need the handler to run *within* the same transaction (strong consistency), use a plain `@EventListener` or `@TransactionalEventListener` without async and think hard about coupling. `@ApplicationModuleListener` is the deliberate 'decoupled, eventually consistent, durable' default.

  • What happens if an event is published with no active transaction?
    By default a @TransactionalEventListener (and thus @ApplicationModuleListener) is not invoked at all, because there is no commit to hook onto. You'd need fallbackExecution = true, or to publish within a transaction. This commonly surprises people in unit tests.
  • Why must @ApplicationModuleListener handlers be idempotent?
    With the Event Publication Registry, incomplete publications are replayed (e.g. republish-on-restart or manual resubmit), giving at-least-once delivery. A handler may therefore process the same event more than once, so it must produce the same result on repetition.

saying these in an interview costs you the question

  • Believing a listener exception rolls back the publisher's transaction
  • Assuming async works without @EnableAsync / a task executor
  • Expecting exactly-once delivery
  • Assuming a listener runs even when the event is published outside a transaction

context