skip to content

Besides @TransactionalEventListener, how can you hook a callback to run after the current transaction commits using the lower-level synchronization API? When would you reach for it?

level: middleimportance: should knowfreq 40%

answer

  1. registerSynchronization(new TransactionSynchronization(){afterCommit})
  2. isSynchronizationActive() guard or IllegalStateException
  3. afterCompletion(status) for both paths
  4. runs on committing thread, sync
  5. escape hatch vs domain event

basics

~10 s

Call TransactionSynchronizationManager.registerSynchronization(...) with a TransactionSynchronization whose afterCommit() method holds your side effect. Spring runs it after the current transaction commits. Use it for imperative, one-off deferrals where publishing a full domain event is overkill.

solid answer

~40 s

Spring exposes the callback API that @TransactionalEventListener is built on: TransactionSynchronizationManager.registerSynchronization(TransactionSynchronization). You override afterCommit() (or beforeCommit / afterCompletion, which receives a status of STATUS_COMMITTED or STATUS_ROLLED_BACK). You must call it while a transaction is active — TransactionSynchronizationManager.isSynchronizationActive() guards that; otherwise it throws IllegalStateException. It runs synchronously on the committing thread. Reach for it for imperative, local, one-off deferrals — 'after this commits, evict this cache key / enqueue this job' — where defining a domain event and a separate listener is ceremony. It couples the caller to the mechanism, so for cross-module decoupling prefer the event-based approach. Same crash-after-commit caveat applies: afterCommit runs outside the transaction and cannot make the side effect atomic with the data.

code

java · 22 lines
java
@Service
class CacheAwareService {
    @Transactional
    public void updateProfile(Profile p) {
        profileRepo.save(p);

        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override public void afterCommit() {
                        // only evict the external cache once the write is durable
                        externalCache.evict("profile:" + p.getId());
                    }
                    @Override public void afterCompletion(int status) {
                        if (status == STATUS_ROLLED_BACK) {
                            log.debug("profile update rolled back, no eviction");
                        }
                    }
                });
        }
    }
}

go deeper

for a junior

Unlikely to know this API; fine to only know the annotation.

for a middle

Should recognize registerSynchronization/afterCommit as the primitive under the annotation and the isSynchronizationActive guard.

for a senior

Articulates when to choose the imperative hook over an event, and the afterCompletion status branching.

for a principal

Sees both as the same synchronization mechanism and reasons about coupling, testability, and the shared crash-window limit.

## The API underneath the annotation `@TransactionalEventListener` is a convenience layer over Spring's **transaction synchronization** machinery. You can use that machinery directly: ```java TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { // side effect here, runs only on successful commit } }); ``` `TransactionSynchronizationManager` is a thread-bound registry of resources and synchronization callbacks for the **current** transaction. ## The `TransactionSynchronization` callbacks - `beforeCommit(boolean readOnly)` — before commit; can still fail the commit by throwing. - `beforeCompletion()` — before completion, both paths. - `afterCommit()` — **after a successful commit** — the hook for deferred side effects. - `afterCompletion(int status)` — after commit or rollback; `status` is `STATUS_COMMITTED`, `STATUS_ROLLED_BACK`, or `STATUS_UNKNOWN`. Use it to run cleanup regardless of outcome. All methods are `default` in modern Spring, so you override only what you need. ## Preconditions and pitfalls - You may only register while synchronization is **active**. Guard with `TransactionSynchronizationManager.isSynchronizationActive()`; registering outside a transaction throws `IllegalStateException`. This is the imperative analogue of the annotation's 'silently dropped' behavior — here it fails loudly. - `afterCommit()` runs **synchronously on the committing thread**, blocking the caller. Offloading to async is your responsibility (submit to an executor inside the callback). - The transaction is **already committed** when `afterCommit()` runs; a new DB write needs its own transaction (e.g. a `TransactionTemplate` with `PROPAGATION_REQUIRES_NEW`). - Throwing from `afterCommit()` propagates but **cannot** undo the commit. ## When to prefer this over an event | Prefer `registerSynchronization` | Prefer `@TransactionalEventListener` | |---|---| | Imperative, local, single call site | Decoupled producer/consumer across modules | | No meaningful domain event to name | A real domain event others may also observe | | Inside infrastructure/util code | Application/domain code | Because the event approach decouples the side effect from the transactional code and is testable in isolation, it is usually the default in a well-structured (e.g. Spring Modulith) codebase. `registerSynchronization` is the escape hatch when defining an event and listener is disproportionate ceremony, or when you're writing framework-level code. ## Same fundamental limit Like the annotation, this only guarantees the callback fires **after durable commit**. It does not survive a JVM crash in the commit→callback window. For at-least-once delivery you still need the outbox pattern.

  • What happens if you call registerSynchronization when no transaction is active?
    It throws IllegalStateException. Unlike @TransactionalEventListener's silent drop, the low-level API fails loudly — so guard with TransactionSynchronizationManager.isSynchronizationActive() when the code path may run without a transaction.
  • Which callback runs on both commit and rollback, and how do you tell them apart?
    afterCompletion(int status). Inspect status against STATUS_COMMITTED, STATUS_ROLLED_BACK, or STATUS_UNKNOWN to branch. afterCommit() runs only on the committed path.

saying these in an interview costs you the question

  • Thinking registerSynchronization can be called anywhere, including outside a transaction, without consequence.
  • Confusing afterCommit (commit only) with afterCompletion (both outcomes).
  • Assuming the callback runs asynchronously — it runs on the committing thread by default.

context