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?
answer
- registerSynchronization(new TransactionSynchronization(){afterCommit})
- isSynchronizationActive() guard or IllegalStateException
- afterCompletion(status) for both paths
- runs on committing thread, sync
- escape hatch vs domain event
basics
~10 sCall 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 sSpring 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@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
Unlikely to know this API; fine to only know the annotation.
Should recognize registerSynchronization/afterCommit as the primitive under the annotation and the isSynchronizationActive guard.
Articulates when to choose the imperative hook over an event, and the afterCompletion status branching.
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.