The full picture
- Cast of components: proxy →
TransactionInterceptor→PlatformTransactionManager(JDBC: DataSourceTransactionManager, JPA: JpaTransactionManager) →TransactionSynchronizationManager(the ThreadLocal registry) → your DataSource's connection. - Why repositories 'just join':
DataSourceUtils.getConnection(ds)/ the JPA EntityManager both consult the ThreadLocal first — same thread, same transaction. Different thread (@Async, CompletableFuture, new Thread) = no bound resource = autocommit or a brand-new transaction. - Synchronizations: afterCommit/afterCompletion callbacks registered on the thread power
@TransactionalEventListenerand cache/messaging coordination. - Read-only is an optimization hint (Hibernate skips dirty-checking/flush; some drivers route to replicas), not an enforcement mechanism.
@Transactional
public void placeOrder(Order o) {
repo.save(o); // TX1, thread A
CompletableFuture.runAsync(() ->
auditRepo.log(o.id())); // ❌ thread B: NOT in TX1 —
} // commits even if TX1 rolls back
// If audit must share the outcome, keep it on the thread;
// if it must survive independently, make that explicit (REQUIRES_NEW or outbox).