Skip to main content
spring internals

Resource Binding and the Thread-Local Transaction Registry

7 min read Chapter 56 of 78

Resource Binding and the Thread-Local Transaction Registry

Every Spring Data JPA repository method needs an EntityManager. Every JdbcTemplate call needs a Connection. Within a @Transactional method, all of them must use the same database transaction. Spring achieves this without passing these objects as parameters. The mechanism is thread-local binding.

TransactionSynchronizationManager is the registry. Resources are bound to the current thread at transaction start and unbound at transaction end. Between those two points, any code running on the same thread can retrieve the bound resources.

The Binding API

public abstract class TransactionSynchronizationManager {

    private static final ThreadLocal<Map<Object, Object>> resources =
        new NamedThreadLocal<>("Transactional resources");

    public static void bindResource(Object key, Object value) {
        Map<Object, Object> map = resources.get();
        if (map == null) {
            map = new HashMap<>();
            resources.set(map);
        }
        Object oldValue = map.put(key, value);
        if (oldValue != null) {
            throw new IllegalStateException(
                "Already value [" + oldValue + "] for key [" + key + "] bound to thread"
            );
        }
    }

    public static Object getResource(Object key) {
        Map<Object, Object> map = resources.get();
        if (map == null) return null;
        return map.get(key);
    }

    public static Object unbindResource(Object key) {
        Map<Object, Object> map = resources.get();
        if (map == null) return null;
        Object value = map.remove(key);
        if (map.isEmpty()) {
            resources.remove();
        }
        return value;
    }
}

The key is typically a factory object: EntityManagerFactory for JPA resources, DataSource for JDBC resources. The value is a holder object wrapping the actual resource.

bindResource() throws if a resource is already bound for the same key on the same thread. This prevents accidental double-binding and signals misuse of the transaction manager.

EntityManagerHolder and ConnectionHolder

Resources are not stored directly. They are wrapped in holder objects that carry additional state:

public class EntityManagerHolder extends ResourceHolderSupport {
    private final EntityManager entityManager;

    // ResourceHolderSupport provides:
    // - synchronizedWithTransaction flag
    // - referenceCount (for nested transactions)
    // - rollbackOnly flag
    // - deadline for timeout
}
public class ConnectionHolder extends ResourceHolderSupport {
    private Connection connection;
    private boolean transactionActive;
}

The holder pattern serves two purposes:

  1. Reference counting: When a transaction suspends (e.g., REQUIRES_NEW) and a new transaction begins, the holder tracks how many transactions reference the resource. The resource is only released when the count reaches zero.

  2. Transaction metadata: The holder carries flags like rollbackOnly and synchronizedWithTransaction that the transaction manager checks during commit/rollback.

JpaTransactionManager: The Full Binding Sequence

When @Transactional triggers on OrderService.createOrder():

@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 audit ...", ...);
        return saved;
    }
}

JpaTransactionManager.doBegin() executes:

Step 1: Create EntityManager
  em = entityManagerFactory.createEntityManager()

Step 2: Wrap in holder
  emHolder = new EntityManagerHolder(em)

Step 3: Bind to thread
  TransactionSynchronizationManager.bindResource(entityManagerFactory, emHolder)

Step 4: Get JDBC Connection from EntityManager
  conn = em.unwrap(SessionImplementor.class).getJdbcCoordinator()
           .getLogicalConnection().getPhysicalConnection()

Step 5: Wrap Connection in holder
  connHolder = new ConnectionHolder(conn)

Step 6: Bind Connection to thread
  TransactionSynchronizationManager.bindResource(dataSource, connHolder)

Step 7: Begin database transaction
  conn.setAutoCommit(false)

After these steps, the thread-local map contains:

Thread-42:
  EntityManagerFactory@7a3d -> EntityManagerHolder(em@9f2c)
  HikariDataSource@3b1e    -> ConnectionHolder(conn@5d4a)

Any code running on Thread-42 can retrieve either resource.

SharedEntityManagerCreator: The EntityManager Proxy

When you inject EntityManager using @PersistenceContext:

@Service
public class CustomOrderService {
    @PersistenceContext
    private EntityManager entityManager;
}

The injected EntityManager is not a real EntityManager. It is a proxy created by SharedEntityManagerCreator. This proxy implements EntityManager and, on every method call, looks up the thread-bound EntityManager:

// SharedEntityManagerCreator.SharedEntityManagerInvocationHandler (simplified)
public Object invoke(Object proxy, Method method, Object[] args) {
    EntityManagerHolder holder =
        TransactionSynchronizationManager.getResource(entityManagerFactory);

    if (holder != null && holder.getEntityManager() != null) {
        // Use the thread-bound EntityManager (inside a transaction)
        return method.invoke(holder.getEntityManager(), args);
    }

    // No transaction active: create a short-lived EntityManager
    EntityManager em = entityManagerFactory.createEntityManager();
    try {
        return method.invoke(em, args);
    } finally {
        em.close();
    }
}

The proxy ensures that @PersistenceContext EntityManager always delegates to the transaction’s EntityManager when one exists. Without the proxy, the injected EntityManager would be a single instance shared across all transactions on all threads, which would be catastrophic.

Spring Data JPA uses the same mechanism internally. When SimpleJpaRepository calls entityManager.persist(), the entityManager field is a SharedEntityManagerCreator proxy. It resolves to the thread-bound EntityManager, ensuring repository operations participate in the current transaction.

How Spring Data Gets the EntityManager

The chain from repository method call to EntityManager lookup:

orderRepository.save(order)
  -> JDK dynamic proxy (CH18-S1)
    -> SimpleJpaRepository.save(order)
      -> this.entityManager.persist(order)
        -> SharedEntityManagerCreator proxy
          -> TransactionSynchronizationManager.getResource(emf)
            -> EntityManagerHolder.getEntityManager()
              -> actual EntityManager.persist(order)

Every repository method call goes through this chain. The proxy lookup is fast (thread-local HashMap get), but it is not free. In a tight loop with thousands of individual saves, the overhead becomes measurable. This is one reason saveAll() exists: batch operations reduce the number of proxy traversals.

Thread Safety and Virtual Threads

ThreadLocal means each thread gets its own copy. On a traditional thread-per-request model, this is safe: each request runs on its own thread, each thread has its own EntityManager, no sharing.

With virtual threads (Java 21), the same contract holds. Virtual threads are still threads. Each virtual thread has its own ThreadLocal storage. The concern with virtual threads is not thread-safety but resource exhaustion: if you spawn 10,000 virtual threads, each with its own EntityManager and Connection, you exhaust the connection pool.

Spring Framework 6.1+ provides TransactionSynchronizationManager awareness for virtual threads. The thread-local binding works identically. The constraint shifts from thread management to connection pool sizing.

The Failure Mode: Manual EntityManager Creation

// BROKEN: manually creating an EntityManager inside a @Transactional method
@Service
public class OrderService {
    @PersistenceUnit
    private EntityManagerFactory entityManagerFactory;

    private final OrderRepository orderRepository;

    @Transactional
    public Order createOrder(Order order) {
        // This EntityManager is NOT the transaction's EntityManager
        EntityManager manualEm = entityManagerFactory.createEntityManager();

        Order saved = orderRepository.save(order);  // Uses thread-bound EM

        // This runs in a SEPARATE persistence context
        manualEm.persist(new AuditEntry(saved.getId(), "CREATED"));
        manualEm.flush();
        manualEm.close();

        return saved;
    }
}

Two EntityManagers exist on the same thread. The repository’s save() uses the thread-bound EntityManager (managed by JpaTransactionManager). The manually created EntityManager is independent. It has its own persistence context. Its persist() call does not participate in the transaction.

If the repository save() succeeds but something later throws and the transaction rolls back, the audit entry persisted by the manual EntityManager is already flushed and committed (assuming autocommit). The data is inconsistent.

Worse: the manually created EntityManager is never closed if an exception occurs before manualEm.close(). Connection leak.

Thread-42 state:
  Bound EM (transaction-managed): em@9f2c -> Connection from pool
  Manual EM (unmanaged):          em@1a3b -> SECOND Connection from pool

  save(order) -> [email protected](order)     [in transaction]
  persist(audit) -> [email protected](audit)  [autocommit, separate connection]

The Correct Pattern

// CORRECT: let Spring manage the EntityManager
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final AuditRepository auditRepository;

    @Transactional
    public Order createOrder(Order order) {
        Order saved = orderRepository.save(order);
        auditRepository.save(new AuditEntry(saved.getId(), "CREATED"));
        return saved;
    }
}

Both repositories use the same thread-bound EntityManager. Both participate in the same transaction. If either fails, both roll back.

If you need EntityManager directly for a criteria query or a native query that does not fit a repository method:

// CORRECT: inject the SharedEntityManagerCreator proxy
@Service
public class OrderService {
    private final OrderRepository orderRepository;

    @PersistenceContext
    private EntityManager entityManager;  // This is a proxy

    @Transactional
    public Order createOrder(Order order) {
        Order saved = orderRepository.save(order);

        // Same thread-bound EntityManager, same transaction
        entityManager.createNativeQuery(
            "INSERT INTO audit (order_id, action) VALUES (?, ?)")
            .setParameter(1, saved.getId())
            .setParameter(2, "CREATED")
            .executeUpdate();

        return saved;
    }
}

The @PersistenceContext EntityManager is a SharedEntityManagerCreator proxy. It resolves to the thread-bound EntityManager. Same persistence context. Same transaction. No leaks.

Never call entityManagerFactory.createEntityManager() inside application code. Let the transaction manager and the SharedEntityManagerCreator proxy handle EntityManager lifecycle. You write business logic. Spring manages plumbing.