Spring Transaction Synchronization and Event-Driven Post-Commit Actions
1. Thread-bound transaction context
Spring coordinates transactional work by binding state to the current thread. The central utility is TransactionSynchronizationManager, which maintains per-thread structures such as:
- Resources: key/value map for things like DataSource → ConnectionHolder
- Registered synchronizers: callbacks to run at specific transaction lifecycle points
- Transaction name
- Read-only flag
- Isolation level
- Actual transaction active flag
Conceptually (not the actual source):
// Conceptual sketch of thread-locals managed by Spring
ThreadLocal<Map<Object, Object>> boundResources;
ThreadLocal<List<TransactionSynchronization>> synchronizers;
ThreadLocal<String> txName;
ThreadLocal<Boolean> readOnly;
ThreadLocal<Integer> isolationLevel;
ThreadLocal<Boolean> txActive;
Binding the state to the executing thread lets Spring ensure that all data-access components (e.g., via JdbcTemplate, JPA, MyBatis) share the same transactional Connection/Session without passing it explicitly through method parameters.
2. How Spring drives a transaction
TransactionInterceptor and TransactionAspectSupport wrap your @Transactional methods. They look up the PlatformTransactionManager, derive metadata from @Transactional (propagation, isolation, timeout, readOnly, rollback rules), start or join a transaction, and finally commit or roll back.
At a low level this abstracts the typical JDBC pattern:
Connection cn = dataSource.getConnection();
cn.setAutoCommit(false);
try {
runBusinessLogic(cn);
cn.commit();
} catch (Throwable ex) {
cn.rollback();
throw ex;
} finally {
cn.close();
}
Spring orchestrates something equivalent and additionally:
- Creates a
TransactionInfowith the manager and attributes - Binds the acquired transactional resource to the current thread
- Invokes business logic
- On exception, performs rollback; otherwise commits
- Unbinds and cleans up thread-local state
Resource binding and lookup
During transaction start, Spring binds the resource holder so other components in the same thread can reuse it:
// When starting the transaction
Connection connection = dataSource.getConnection();
connection.setAutoCommit(false);
TransactionSynchronizationManager.bindResource(
dataSource, new ConnectionHolder(connection)
);
// Somewhere else in the same thread (e.g., DAO)
ConnectionHolder holder = (ConnectionHolder)
TransactionSynchronizationManager.getResource(dataSource);
Connection shared = holder.getConnection();
Because the transaction context is thread-local, code that forks to additional threads cannot automatically participate in the same database transaction.
3. TransactionSynchronization: lifecycle hooks
Spring exposes transaction lifecycle callbacks via TransactionSynchronization. Registered synchronizers receive notifications such as before-commit and after-completion, enabling cross-cutting behavior synchronized with the transaction boundary.
Key callbacks:
beforeCommit(boolean readOnly): just before commitbeforeCompletion(): before the transaction completes (both commit and rollback)afterCommit(): only after a successful commitafterCompletion(int status): after completion with status (committed or rolled back)flush(),suspend(),resume(): advanced scenarios
Example: publish to MQ only after the DB commit succeeds
If message publication is placed inside the same database transaction, slow I/O can hold the connection unnecessarily. A better approach is to commit the DB work first and send the message in afterCommit.
@Transactional
public void finalizeOrder(Order order) {
// Persist successful order changes
orderRepository.markCompleted(order);
// Defer MQ publish until the transaction actually commits
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() {
mqClient.send(order);
}
// No-ops for other methods in this example
@Override public void beforeCommit(boolean readOnly) {}
@Override public void beforeCompletion() {}
@Override public void afterCompletion(int status) {}
@Override public void suspend() {}
@Override public void resume() {}
@Override public void flush() {}
});
}
The message is sent only if the surrounding transaction commits, preventing consumers from observing changes that were later rolled back.
4. Declarative events with @TransactionalEventListener
From Spring 4.2 onward, you can publish an application event and have it delivered only at a specific transaction phase via @TransactionalEventListener. This removes the need to manually register a synchronization.
@Service
public class OrderService {
private final ApplicationEventPublisher events;
private final OrderRepository orderRepository;
public OrderService(ApplicationEventPublisher events, OrderRepository orderRepository) {
this.events = events;
this.orderRepository = orderRepository;
}
@Transactional
public void finish(Order order) {
orderRepository.markCompleted(order);
// Event is raised inside the transaction
events.publishEvent(new OrderCommittedEvent(order));
}
}
@Component
class OrderEventHandler {
private final MqClient mqClient;
OrderEventHandler(MqClient mqClient) { this.mqClient = mqClient; }
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onAfterCommit(OrderCommittedEvent evt) {
mqClient.send(evt.getOrder());
}
}
final class OrderCommittedEvent {
private final Order order;
OrderCommittedEvent(Order order) { this.order = order; }
public Order getOrder() { return order; }
}
How it works under the hood
Methods annotated with @TransactionalEventListener are adapted at runtime. Spring creates a listener that, when invoked, defers actual delivery by registering a TransactionSynchronization whose behavior depends on the configured phase. A simplified sketch of the adapter logic:
final class TxSyncEventBridge implements TransactionSynchronization, Ordered {
private final ApplicationListenerMethodAdapter delegate;
private final Object event;
private final TransactionPhase phase;
TxSyncEventBridge(ApplicationListenerMethodAdapter delegate, Object event, TransactionPhase phase) {
this.delegate = delegate;
this.event = event;
this.phase = phase;
}
@Override public int getOrder() { return delegate.getOrder(); }
@Override public void beforeCommit(boolean readOnly) {
if (phase == TransactionPhase.BEFORE_COMMIT) dispatch();
}
@Override public void afterCompletion(int status) {
if (phase == TransactionPhase.AFTER_COMMIT && status == STATUS_COMMITTED) dispatch();
else if (phase == TransactionPhase.AFTER_ROLLBACK && status == STATUS_ROLLED_BACK) dispatch();
else if (phase == TransactionPhase.AFTER_COMPLETION) dispatch();
}
private void dispatch() { delegate.processEvent(event); }
// Unused callbacks for brevity
@Override public void beforeCompletion() {}
@Override public void suspend() {}
@Override public void resume() {}
@Override public void flush() {}
}
TransactionalEventListenerFactory wires this up sothat publishing an event inside a transaction results in a synchronization being registered with TransactionSynchronizationManager, ensuring delivery exactly at the requested transaction phase.