Definition
- TransactionSynchronization -- an interface with callback methods (
beforeCommit,afterCommit,beforeCompletion,afterCompletion) that Spring calls at well-defined points in the transaction lifecycle. - registerSynchronization -- a method on
TransactionSynchronizationManagerthat registers a callback implementation while a transaction is active. - beforeCommit(boolean readOnly) -- runs just before the commit is issued; throwing here triggers a rollback.
- afterCommit() -- runs only after a successful commit; the data is durable and it is safe to send external notifications.
- afterCompletion(int status) -- runs after the transaction ends regardless of outcome, with
STATUS_COMMITTEDorSTATUS_ROLLED_BACK.
Imagine Rajesh walks into the National Bank of India to transfer Rs.50,000 to Priya. The teller does not send Rajesh an SMS the moment he fills the form, because the form might still be rejected. The teller waits until the vault actually records the money movement and the register is stamped (afterCommit), and only then sends the SMS confirmation. If the form is rejected (afterCompletion with ROLLED_BACK), the teller sends a failure notification instead. TransactionSynchronization is this well-mannered teller: it runs the right action at the right moment.
Understanding the Concepts
What is TransactionSynchronization?
TransactionSynchronization is a Spring interface that lets you register callback methods that execute at specific points in the current transaction's lifecycle. Spring keeps a per-thread list of synchronizations bound to the active transaction. You add to that list by calling TransactionSynchronizationManager.registerSynchronization(...). This mechanism is the low-level foundation that @TransactionalEventListener is built on.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.math.BigDecimal;
@Service
public class TransferService {
private final AccountRepository accountRepo;
private final SmsGateway smsGateway;
public TransferService(AccountRepository accountRepo,
SmsGateway smsGateway) {
this.accountRepo = accountRepo;
this.smsGateway = smsGateway;
}
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
accountRepo.debit(fromId, amount);
accountRepo.credit(toId, amount);
// Register a callback to send SMS only after commit
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
smsGateway.send(fromId,
"Rs." + amount + " transferred to " + toId);
}
});
}
}
Execution order:
1. debit() -- SQL UPDATE
2. credit() -- SQL UPDATE
3. registerSynchronization() -- callback stored in thread-local list
4. COMMIT issued by Spring
5. afterCommit() fires -- SMS sent to Rajesh
Why does TransactionSynchronization exist?
It exists because side effects that depend on the transaction outcome must not run until the outcome is known. Sending an SMS inside the transaction body means the SMS is sent before the commit happens. If the commit later fails, the customer receives a false confirmation. TransactionSynchronization solves this by providing precise callback hooks at every lifecycle point.
The callbacks cover four moments: before the commit is issued (last chance to validate), after a successful commit (safe for notifications), before the transaction ends regardless of outcome (resource release), and after the transaction ends regardless of outcome (cleanup and metrics).
package com.bank.service;
import org.springframework.transaction.support.TransactionSynchronization;
public class TransferSynchronization implements TransactionSynchronization {
private final String accountId;
private final String message;
private final SmsGateway smsGateway;
private final AccountCache cache;
public TransferSynchronization(String accountId, String message,
SmsGateway smsGateway,
AccountCache cache) {
this.accountId = accountId;
this.message = message;
this.smsGateway = smsGateway;
this.cache = cache;
}
@Override
public void beforeCommit(boolean readOnly) {
// Last chance to validate before commit
System.out.println("About to commit transfer for " + accountId);
}
@Override
public void afterCommit() {
// Safe for external side effects -- data is durable
smsGateway.send(accountId, message);
}
@Override
public void beforeCompletion() {
// Release resources before TX ends
System.out.println("TX ending for " + accountId);
}
@Override
public void afterCompletion(int status) {
// Always runs -- clean up regardless of outcome
cache.evict(accountId);
if (status == STATUS_ROLLED_BACK) {
System.out.println("Transfer rolled back for " + accountId);
}
}
}
How does beforeCommit work?
The beforeCommit(boolean readOnly) callback runs just before Spring tells the database to commit. It receives a boolean indicating whether the transaction is read-only. This is the last moment where you can still influence the transaction. If you throw an exception in beforeCommit, the transaction is rolled back.
This is the right place for a final validation, such as verifying that the ledger balances before the transfer is committed.
package com.bank.sync;
import org.springframework.transaction.support.TransactionSynchronization;
public class LedgerValidationSync implements TransactionSynchronization {
private final LedgerService ledgerService;
public LedgerValidationSync(LedgerService ledgerService) {
this.ledgerService = ledgerService;
}
@Override
public void beforeCommit(boolean readOnly) {
if (!readOnly && !ledgerService.isBalanced()) {
throw new IllegalStateException(
"Ledger not balanced -- aborting commit");
}
}
}
Scenario 1: ledger is balanced
beforeCommit() -- passes, no exception
COMMIT proceeds normally
Scenario 2: ledger is NOT balanced
beforeCommit() -- throws IllegalStateException
COMMIT is cancelled, ROLLBACK is issued
How does afterCommit work?
The afterCommit() callback runs only if the transaction committed successfully. At this point the data is durable in the database. This is the safe place for external side effects: sending SMS, publishing to Kafka, calling a webhook, or triggering an email.
A critical nuance is that the original transaction is already finished by the time afterCommit runs. If you need to write to the database inside this callback, you need a new transaction.
package com.bank.sync;
import org.springframework.transaction.support.TransactionSynchronization;
public class NotificationSync implements TransactionSynchronization {
private final String accountId;
private final SmsGateway smsGateway;
public NotificationSync(String accountId, SmsGateway smsGateway) {
this.accountId = accountId;
this.smsGateway = smsGateway;
}
@Override
public void afterCommit() {
// Data is durable -- safe to notify
smsGateway.send(accountId,
"Your transfer has been completed successfully.");
}
}
How does afterCompletion work?
The afterCompletion(int status) callback always runs at the very end of the transaction, regardless of whether it committed or rolled back. The status parameter tells you the outcome: STATUS_COMMITTED, STATUS_ROLLED_BACK, or STATUS_UNKNOWN.
This is the right place for cache eviction, metric updates, and resource cleanup that must happen no matter what.
package com.bank.sync;
import org.springframework.transaction.support.TransactionSynchronization;
public class CacheCleanupSync implements TransactionSynchronization {
private final String accountId;
private final AccountCache cache;
private final MetricsService metrics;
public CacheCleanupSync(String accountId,
AccountCache cache,
MetricsService metrics) {
this.accountId = accountId;
this.cache = cache;
this.metrics = metrics;
}
@Override
public void afterCompletion(int status) {
cache.evict(accountId);
if (status == STATUS_COMMITTED) {
metrics.incrementSuccessfulTransfers();
} else if (status == STATUS_ROLLED_BACK) {
metrics.incrementFailedTransfers();
}
}
}
After successful commit:
afterCompletion(STATUS_COMMITTED)
cache evicted, success metric incremented
After rollback:
afterCompletion(STATUS_ROLLED_BACK)
cache evicted, failure metric incremented
How is TransactionSynchronization different from @TransactionalEventListener?
@TransactionalEventListener is a higher-level abstraction built on top of TransactionSynchronization. Both achieve the same goal, but they differ in how you register and use them.
| Aspect | TransactionSynchronization | @TransactionalEventListener |
|---|---|---|
| Registration | Programmatic: call registerSynchronization() | Declarative: annotate a method |
| Scope | One-off: registered within a specific method | Global: listens for all events of a type |
| Coupling | Tightly coupled to the service method | Loosely coupled via events |
| Reusability | Low: inline or dedicated class | High: any publisher can trigger it |
| Testability | Harder to unit test | Easier: mock the event |
// TransactionSynchronization: programmatic, one-off
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
accountRepo.debit(fromId, amount);
accountRepo.credit(toId, amount);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
smsGateway.send(fromId, "Transfer done");
}
});
}
// @TransactionalEventListener: declarative, reusable
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
accountRepo.debit(fromId, amount);
accountRepo.credit(toId, amount);
eventPublisher.publishEvent(new TransferEvent(fromId, toId, amount));
}
// Separate listener class
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTransfer(TransferEvent event) {
smsGateway.send(event.getFromAccountId(), "Transfer done");
}
What are common mistakes?
The first mistake is calling registerSynchronization() outside an active transaction. Spring throws an IllegalStateException if no transaction is active. Always guard the registration.
package com.bank.service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class SafeRegistration {
// WRONG: no guard, crashes if called outside TX
public void registerWrong() {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
System.out.println("Committed");
}
});
}
// CORRECT: guard with isSynchronizationActive()
public void registerCorrect() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
System.out.println("Committed");
}
});
} else {
// Fallback: run immediately or log a warning
System.out.println("No active TX, running immediately");
}
}
}
The second mistake is assuming that an exception in afterCommit rolls back the transaction. By the time afterCommit runs, the commit decision is already final. An exception there is only logged, and the transaction remains committed.
// WRONG assumption: afterCommit exception rolls back TX
@Override
public void afterCommit() {
// If this throws, the TX is NOT rolled back
externalApi.callPartner(); // throws RuntimeException
}
// Result: money moved, but partner call failed
// You must handle your own errors here
What should a senior engineer know?
A senior engineer knows that TransactionSynchronization callbacks run on the same thread that committed the transaction. A slow SMS call in afterCommit blocks the request thread. For production systems, delegate to an async executor or use @TransactionalEventListener with @Async.
Senior engineers also know about TransactionSynchronizationUtils.triggerAfterCommit() for testing, and that TransactionSynchronizationManager stores resources in ThreadLocal, which means synchronizations do not work across threads or in reactive applications without special handling.
package com.bank.service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AsyncAfterCommit {
private static final ExecutorService EXECUTOR =
Executors.newFixedThreadPool(4);
public static void registerAsyncAfterCommit(Runnable task) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
EXECUTOR.submit(task); // non-blocking
}
});
}
}
What are common interview follow-up questions?
- What is the difference between
beforeCommitandbeforeCompletion? - Can you register multiple synchronizations in the same transaction? In what order do they run?
- What happens if
beforeCommitthrows an exception? - How does
@TransactionalEventListeneruseTransactionSynchronizationinternally? - How do you test a
TransactionSynchronizationcallback in a unit test?
Visual Overview
Transaction Lifecycle with Synchronization Callbacks
BEGIN ──▶ debit() ──▶ credit() ──▶ registerSynchronization()
│
▼
┌──────────────────────┐
│ beforeCommit() │ ◀── last chance to validate
│ (can abort TX) │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ beforeCompletion() │ ◀── release resources
└──────────┬───────────┘
│
┌─────┴─────┐
│ COMMIT │
└─────┬─────┘
│
┌──────────▼───────────┐
│ afterCommit() │ ◀── send SMS (data is durable)
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ afterCompletion() │ ◀── cache evict, metrics
│ status=COMMITTED │
└──────────────────────┘
If ROLLBACK instead of COMMIT:
beforeCommit() -- does NOT run
beforeCompletion() -- RUNS
afterCommit() -- does NOT run
afterCompletion() -- RUNS (status=ROLLED_BACK)
Key Takeaway
TransactionSynchronization is Spring's callback interface for hooking custom logic into the lifecycle of the current transaction. You register an implementation using TransactionSynchronizationManager.registerSynchronization while a transaction is active, and Spring calls your callbacks at precise moments. The beforeCommit method is your last chance to enforce an invariant or abort the transaction. The afterCommit method fires only when the money has truly moved, making it the safe place for SMS and Kafka messages. The afterCompletion method always runs with a status telling you the outcome, making it the right place for cache eviction and metrics.
In the National Bank of India transfer system, Pankaj the architect moved all external notifications into afterCommit callbacks. The compliance audit row was written in a beforeCommit listener so it committed atomically with the money movement. The cache eviction went in afterCompletion so stale account balances were always cleared regardless of outcome. This design eliminated false confirmations and ensured cleanup always happened.
The critical thing to remember is that registration only works inside an active transaction, so always guard it with isSynchronizationActive(). An exception thrown inside afterCommit does not roll back the transaction because the commit decision is already final. In modern code, prefer the higher-level @TransactionalEventListener annotation, which is built on this exact mechanism.
Remember one sentence: afterCommit is for side effects the customer will trust, afterCompletion is for cleanup that must always happen.
---