Transaction Synchronization: How Spring Coordinates Multiple Resources in One Transaction
Transaction Synchronization: How Spring Coordinates Multiple Resources in One Transaction
CH10 covered how @Transactional creates a proxy that wraps method calls in transaction begin/commit/rollback logic. That chapter showed the proxy mechanism. This chapter shows what happens inside the transaction boundary: how the EntityManager and the JDBC Connection end up participating in the same transaction, how post-commit callbacks work, and why LazyInitializationException exists.
The central piece is TransactionSynchronizationManager. It is a class of static methods backed by ThreadLocal variables. It is the registry that makes Spring’s transaction abstraction work.
TransactionSynchronizationManager: The Thread-Local Registry
TransactionSynchronizationManager holds two categories of thread-local state:
Resources: objects bound to the current thread, keyed by a factory object.
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
Synchronizations: callbacks registered for the current transaction lifecycle.
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
When a @Transactional method begins, the transaction manager binds resources (EntityManager, Connection) to the current thread. When repository methods execute, they look up these resources from the same thread-local storage. When the transaction completes, the resources are unbound and the synchronization callbacks fire.
This is how Spring achieves “one transaction, one EntityManager, one Connection” without passing these objects as method parameters. Everything routes through the thread.
How JpaTransactionManager Binds Resources
When OrderService.createOrder() is annotated @Transactional and gets called, the transaction proxy delegates to JpaTransactionManager. Here is the sequence:
1. Transaction Begin
// JpaTransactionManager.doBegin() simplified
EntityManager em = entityManagerFactory.createEntityManager();
EntityManagerHolder emHolder = new EntityManagerHolder(em);
// Bind EntityManager to the current thread
TransactionSynchronizationManager.bindResource(
entityManagerFactory, emHolder
);
// Get the JDBC Connection from the EntityManager's session
Connection conn = getJdbcConnection(em);
ConnectionHolder connHolder = new ConnectionHolder(conn);
// Bind Connection to the current thread
TransactionSynchronizationManager.bindResource(
dataSource, connHolder
);
// Begin the actual database transaction
conn.setAutoCommit(false);
After doBegin(), the current thread has two resources bound:
EntityManagerFactory -> EntityManagerHolder(em)DataSource -> ConnectionHolder(conn)
2. Method Execution
Inside createOrder(), the code calls orderRepository.save(order). The repository proxy delegates to SimpleJpaRepository.save(), which needs an EntityManager. It gets one through SharedEntityManagerCreator:
// SharedEntityManagerCreator (proxy) resolves EntityManager from thread
EntityManager em = EntityManagerHolder holder =
TransactionSynchronizationManager.getResource(entityManagerFactory);
return holder.getEntityManager();
The same EntityManager that was bound during transaction begin. The same Connection. One transaction.
If createOrder() also calls a JDBC JdbcTemplate query:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final JdbcTemplate jdbcTemplate;
@Transactional
public Order createOrder(Order order) {
Order saved = orderRepository.save(order);
jdbcTemplate.update(
"INSERT INTO order_audit (order_id, action, tenant_id) VALUES (?, ?, ?)",
saved.getId(), "CREATED", order.getTenantId()
);
return saved;
}
}
JdbcTemplate calls DataSourceUtils.getConnection(dataSource), which looks up the thread-bound Connection:
ConnectionHolder holder =
TransactionSynchronizationManager.getResource(dataSource);
return holder.getConnection();
Same Connection. Same transaction. The JPA save() and the JDBC update() are both part of one database transaction because they share the thread-bound Connection.
3. Transaction Commit
// JpaTransactionManager.doCommit() simplified
EntityManagerHolder emHolder =
TransactionSynchronizationManager.getResource(entityManagerFactory);
emHolder.getEntityManager().flush(); // Write pending changes to DB
emHolder.getEntityManager().getTransaction().commit(); // Commit DB transaction
4. Resource Cleanup
// JpaTransactionManager.doCleanupAfterCompletion()
TransactionSynchronizationManager.unbindResource(entityManagerFactory);
TransactionSynchronizationManager.unbindResource(dataSource);
entityManager.close();
After cleanup, the thread-local storage is empty. The EntityManager is closed. The Connection is returned to the pool.
TransactionSynchronization Callbacks
The synchronization callbacks let you hook into the transaction lifecycle. The TransactionSynchronization interface:
public interface TransactionSynchronization {
default void suspend() {}
default void resume() {}
default void flush() {}
default void beforeCommit(boolean readOnly) {}
default void beforeCompletion() {}
default void afterCommit() {}
default void afterCompletion(int status) {}
}
The callback ordering during a successful commit:
beforeCommit(readOnly)- transaction is about to commit, can still throw to force rollbackbeforeCompletion()- last chance before the commit, used for resource cleanup- Database COMMIT executes
afterCommit()- commit succeeded, safe to perform external actionsafterCompletion(STATUS_COMMITTED)- final cleanup, receives the outcome
During a rollback:
beforeCompletion()- cleanup before rollback- Database ROLLBACK executes
afterCompletion(STATUS_ROLLED_BACK)- final cleanup, receives the rollback status
The critical distinction: afterCommit() only runs if the transaction committed successfully. afterCompletion() runs in both cases, with a status flag.
Post-Commit Actions in the SaaS Backend
After creating an order, the SaaS backend sends a notification. The notification must only go out if the order is persisted. If the transaction rolls back, no notification.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
@Transactional
public Order createOrder(Order order) {
Order saved = orderRepository.save(order);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
notificationService.sendOrderCreated(saved.getId());
}
}
);
return saved;
}
}
The afterCommit() callback runs after the database has confirmed the commit. If orderRepository.save() throws and the transaction rolls back, the callback never fires.
Open EntityManager in View (OSIV)
Spring Boot enables OSIV by default through OpenEntityManagerInViewInterceptor. This interceptor binds an EntityManager to the thread before the controller executes and unbinds it after the view renders.
Without OSIV:
Request arrives
-> Controller calls @Transactional service method
-> EntityManager bound to thread (by transaction manager)
-> Repository queries execute
-> Transaction commits
-> EntityManager closed, unbound from thread
-> Controller returns entity to serializer
-> Serializer accesses order.getLineItems() // LAZY LOAD
-> No EntityManager -> LazyInitializationException
With OSIV:
Request arrives
-> OpenEntityManagerInViewInterceptor binds EntityManager
-> Controller calls @Transactional service method
-> Transaction uses the already-bound EntityManager
-> Repository queries execute
-> Transaction commits
-> EntityManager stays open (bound by OSIV, not by transaction)
-> Controller returns entity to serializer
-> Serializer accesses order.getLineItems() // LAZY LOAD
-> EntityManager still open -> query executes (outside transaction, read-only)
-> OpenEntityManagerInViewInterceptor unbinds EntityManager
Response sent
OSIV prevents LazyInitializationException by keeping the EntityManager alive for the entire request. The tradeoff: lazy loading outside the transaction boundary executes queries without transactional guarantees, holds database connections longer, and hides the fact that your service layer did not load all necessary data.
For the SaaS backend, disable OSIV:
spring:
jpa:
open-in-view: false
And explicitly load what you need:
public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"lineItems", "customer"})
Optional<Order> findWithDetailsById(UUID id);
}
The Failure Mode: LazyInitializationException
// BROKEN: accessing lazy collection outside transaction boundary (OSIV disabled)
@RestController
public class OrderController {
private final OrderService orderService;
@GetMapping("/api/tenants/{tenantId}/orders/{orderId}")
public OrderResponse getOrder(@PathVariable UUID tenantId,
@PathVariable UUID orderId) {
Order order = orderService.findById(tenantId, orderId);
// Transaction already committed inside findById()
return new OrderResponse(
order.getId(),
order.getStatus(),
order.getLineItems().size() // LazyInitializationException
);
}
}
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findById(UUID tenantId, UUID orderId) {
return orderRepository.findByTenantIdAndId(tenantId, orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}
The @Transactional boundary is the service method. When findById() returns, the transaction commits and the EntityManager is unbound from the thread. The order entity is now detached. Accessing order.getLineItems() triggers lazy loading, but there is no EntityManager to execute the query.
org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role:
com.saas.order.Order.lineItems: could not initialize proxy - no Session
The Correct Pattern
// CORRECT: load required data within the transaction boundary
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findByIdWithDetails(UUID tenantId, UUID orderId) {
return orderRepository.findWithDetailsByTenantIdAndId(tenantId, orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}
public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"lineItems", "customer"})
Optional<Order> findWithDetailsByTenantIdAndId(UUID tenantId, UUID id);
}
@EntityGraph tells Hibernate to use a LEFT JOIN FETCH to load lineItems and customer in the same query. When the method returns, the entity has all needed data. No lazy loading required outside the transaction.
Alternative: use a JPQL fetch join:
@Query("SELECT o FROM Order o " +
"LEFT JOIN FETCH o.lineItems " +
"LEFT JOIN FETCH o.customer " +
"WHERE o.tenantId = :tenantId AND o.id = :orderId")
Optional<Order> findWithDetailsByTenantIdAndId(
@Param("tenantId") UUID tenantId,
@Param("orderId") UUID orderId
);
Both approaches solve the same problem: ensure all data is loaded before the transaction ends. The transaction boundary is the data loading boundary. Plan accordingly.