Transaction management for JPA EntityManager instances with Spring's declarative transaction infrastructure.
import jakarta.persistence.EntityManagerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableTransactionManagement // CRITICAL: Required for @Transactional
public class JpaConfig {
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}@Transactional(
// === Transaction Manager ===
// Specify bean name if multiple transaction managers exist
value = "transactionManager", // Bean name of PlatformTransactionManager
transactionManager = "transactionManager", // Alias for 'value'
// === Propagation (how method joins existing transactions) ===
propagation = Propagation.REQUIRED, // DEFAULT
// REQUIRED: Use existing or create new (DEFAULT) - 99% of use cases
// REQUIRES_NEW: Always create new, suspend existing - for independent operations
// NESTED: Nested with savepoint (requires JDBC 3.0+) - partial rollback
// SUPPORTS: Use if exists, else non-transactional - rare, usually avoid
// NOT_SUPPORTED: Suspend any existing transaction - performance optimization
// MANDATORY: Must have existing, else throw exception - validation
// NEVER: Must not have existing, else throw exception - validation
// === Isolation Level (SQL transaction isolation) ===
isolation = Isolation.DEFAULT, // DEFAULT
// DEFAULT: Use database default (usually READ_COMMITTED)
// READ_UNCOMMITTED: Dirty reads possible (AVOID - data integrity issues)
// READ_COMMITTED: Prevents dirty reads (RECOMMENDED) - PostgreSQL/MySQL default
// REPEATABLE_READ: Prevents dirty and non-repeatable reads - MySQL InnoDB default
// SERIALIZABLE: Highest isolation, prevents phantom reads (SLOWEST)
// === Timeout (in seconds) ===
timeout = 30, // Throws TransactionTimedOutException if exceeds
// Default: -1 (no timeout)
// Recommended: 30-60 seconds for normal operations, 300+ for batch jobs
// === Read-Only Optimization ===
readOnly = false, // false (default) for writes, true for reads
// Benefits of readOnly=true:
// - Hibernate skips dirty checking (10-30% faster)
// - Flush mode set to MANUAL
// - Database read-only optimizations
// - JDBC connection marked read-only
// === Exception Handling ===
// Rollback on these exceptions (in addition to RuntimeException)
rollbackFor = {Exception.class},
rollbackForClassName = {"java.lang.Exception"},
// Don't rollback on these exceptions
noRollbackFor = {BusinessException.class},
noRollbackForClassName = {"com.example.BusinessException"},
// === Transaction Labels (for monitoring) ===
label = {"service-layer", "user-operation"} // Spring 5.3+
)
public void transactionalMethod() { }Choose propagation based on:
REQUIRED (default)
└─ Normal operations where you want to join existing transaction or create new
Examples: standard CRUD, business logic
✅ Use: 99% of cases
✅ Simple and correct
REQUIRES_NEW
└─ Operations that MUST commit separately from caller
Examples: audit logging, event recording, statistics
⚠️ Use: Independent operations that must persist even if caller fails
⚠️ Performance impact (transaction suspension)
NESTED
└─ Operations that can partially rollback using savepoints
Examples: optional sub-operations, retry logic
⚠️ Use: Requires JDBC savepoint support
⚠️ Not supported by all databases
SUPPORTS
└─ Read operations that don't require transaction
Examples: cached reads, optional transactional behavior
⚠️ Use: Rarely needed, usually use REQUIRED or NOT_SUPPORTED
NOT_SUPPORTED
└─ Operations that should never run in transaction
Examples: external API calls, long-running operations
⚠️ Use: Performance optimization for non-database operations
MANDATORY
└─ Must be called within existing transaction
Examples: internal helper methods
⚠️ Use: Validation that caller has transaction
NEVER
└─ Must not be called within transaction
Examples: operations that start their own transaction
⚠️ Use: Validation that caller has no transactionCalling Method │ Called Method │ Result
────────────────┼────────────────────┼──────────────────────────
@Transactional │ REQUIRED (default)│ Called joins caller's transaction
No transaction │ REQUIRED │ Called creates new transaction
────────────────┼────────────────────┼──────────────────────────
@Transactional │ REQUIRES_NEW │ Called suspends caller's, creates new
No transaction │ REQUIRES_NEW │ Called creates new transaction
────────────────┼────────────────────┼──────────────────────────
@Transactional │ NESTED │ Called creates savepoint in caller's
No transaction │ NESTED │ Called creates new transaction
────────────────┼────────────────────┼──────────────────────────
@Transactional │ SUPPORTS │ Called joins caller's transaction
No transaction │ SUPPORTS │ Called runs non-transactionally
────────────────┼────────────────────┼──────────────────────────
@Transactional │ NOT_SUPPORTED │ Called suspends caller's transaction
No transaction │ NOT_SUPPORTED │ Called runs non-transactionally
────────────────┼────────────────────┼──────────────────────────
@Transactional │ MANDATORY │ Called joins caller's transaction
No transaction │ MANDATORY │ ❌ IllegalTransactionStateException
────────────────┼────────────────────┼──────────────────────────
@Transactional │ NEVER │ ❌ IllegalTransactionStateException
No transaction │ NEVER │ Called runs non-transactionallypackage com.example.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional // Default: REQUIRED propagation, readOnly=false
public class UserService {
@PersistenceContext
private EntityManager entityManager;
// CREATE
public User create(String name, String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
entityManager.persist(user);
return user; // Auto-flushed on commit
}
// READ - optimized with readOnly=true
@Transactional(readOnly = true) // 10-30% faster for reads
public User findById(Long id) {
return entityManager.find(User.class, id);
}
// READ with Optional
@Transactional(readOnly = true)
public Optional<User> findOptionalById(Long id) {
return Optional.ofNullable(entityManager.find(User.class, id));
}
// READ multiple
@Transactional(readOnly = true)
public List<User> findAll() {
return entityManager.createQuery("SELECT u FROM User u", User.class)
.getResultList();
}
// UPDATE (automatic dirty checking)
public void updateName(Long id, String name) {
User user = entityManager.find(User.class, id);
if (user != null) {
user.setName(name); // NO merge() needed - changes auto-detected
}
}
// UPDATE with verification
public boolean updateNameIfExists(Long id, String name) {
User user = entityManager.find(User.class, id);
if (user != null) {
user.setName(name);
return true;
}
return false;
}
// DELETE
public void delete(Long id) {
User user = entityManager.getReference(User.class, id); // Lazy proxy
entityManager.remove(user);
}
// DELETE with verification
public boolean deleteIfExists(Long id) {
User user = entityManager.find(User.class, id);
if (user != null) {
entityManager.remove(user);
return true;
}
return false;
}
// BULK OPERATIONS
@Transactional
public int deleteInactive() {
return entityManager.createQuery(
"DELETE FROM User u WHERE u.active = false")
.executeUpdate();
}
@Transactional
public int updateStatusByEmail(String email, String status) {
return entityManager.createQuery(
"UPDATE User u SET u.status = :status WHERE u.email = :email")
.setParameter("status", status)
.setParameter("email", email)
.executeUpdate();
}
}package com.example.service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true) // All methods read-only by default
public class ReportService {
@PersistenceContext
private EntityManager entityManager;
// Benefits of readOnly = true at class level:
// 1. Hibernate skips dirty checking for ALL methods (10-30% faster)
// 2. Flush mode set to MANUAL (no auto-flush overhead)
// 3. Database applies read-only optimizations
// 4. JDBC connection marked read-only
// 5. Clear intent: this service only reads data
public List<User> findAllUsers() {
return entityManager.createQuery("SELECT u FROM User u", User.class)
.getResultList();
}
public UserStatistics getUserStatistics() {
Long totalUsers = entityManager.createQuery(
"SELECT COUNT(u) FROM User u", Long.class)
.getSingleResult();
Long activeUsers = entityManager.createQuery(
"SELECT COUNT(u) FROM User u WHERE u.active = true", Long.class)
.getSingleResult();
return new UserStatistics(totalUsers, activeUsers);
}
// Override for write operation (rare in report service)
@Transactional(readOnly = false)
public void cacheReport(Report report) {
entityManager.persist(report);
}
}Use REQUIRES_NEW when an operation MUST commit separately, even if the caller fails.
package com.example.service;
import org.springframework.transaction.annotation.Propagation;
import java.time.LocalDateTime;
@Service
public class AuditService {
@PersistenceContext
private EntityManager entityManager;
// Always commits separately, even if caller rolls back
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logEvent(String eventType, String details, Long userId) {
AuditLog log = new AuditLog();
log.setEventType(eventType);
log.setDetails(details);
log.setUserId(userId);
log.setTimestamp(LocalDateTime.now());
entityManager.persist(log);
// Commits independently when method returns
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logError(String errorMessage, String stackTrace) {
ErrorLog error = new ErrorLog();
error.setMessage(errorMessage);
error.setStackTrace(stackTrace);
error.setTimestamp(LocalDateTime.now());
entityManager.persist(error);
// Always persisted, even if caller fails
}
}
@Service
public class OrderService {
@Autowired
private AuditService auditService;
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void processOrder(OrderRequest request) {
// Main transaction
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setAmount(request.getAmount());
entityManager.persist(order);
// Audit log in SEPARATE transaction (always commits)
auditService.logEvent("ORDER_CREATED",
"Order ID: " + order.getId(),
request.getCustomerId());
// If this throws, order rolls back but audit log remains
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
processPayment(order);
}
}Use REQUIRES_NEW for:
Avoid REQUIRES_NEW for:
Use NESTED for operations that can partially rollback without affecting the main transaction.
package com.example.service;
import org.springframework.transaction.annotation.Propagation;
@Service
public class OrderService {
@Autowired
private DiscountService discountService;
@Autowired
private PaymentService paymentService;
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void processOrder(OrderRequest request) {
Order order = new Order();
order.setCustomerId(request.getCustomerId());
order.setAmount(request.getAmount());
entityManager.persist(order);
try {
// Creates savepoint - can roll back to here
discountService.applyDiscountCode(order, request.getDiscountCode());
} catch (InvalidDiscountException e) {
// Rolls back ONLY applyDiscountCode (to savepoint)
log.warn("Invalid discount code {}, continuing without discount",
request.getDiscountCode());
}
try {
// Another savepoint
paymentService.applyLoyaltyPoints(order, request.getCustomerId());
} catch (InsufficientPointsException e) {
// Rolls back ONLY applyLoyaltyPoints (to savepoint)
log.warn("Insufficient loyalty points, continuing without points");
}
finalizeOrder(order);
// Main transaction commits (including order creation and successful sub-operations)
}
}
@Service
public class DiscountService {
@PersistenceContext
private EntityManager entityManager;
@Transactional(propagation = Propagation.NESTED)
public void applyDiscountCode(Order order, String code) {
DiscountCode dc = entityManager.find(DiscountCode.class, code);
if (dc == null || !dc.isValid()) {
throw new InvalidDiscountException("Invalid discount code: " + code);
}
order.setDiscount(dc.getAmount());
dc.incrementUsageCount();
entityManager.merge(dc);
// If exception thrown, rolls back to savepoint (before this method)
}
}NESTED requires:
Savepoint Support by Database:
| Database | Savepoint Support | Notes |
|---|---|---|
| PostgreSQL | ✅ YES | Full support |
| Oracle | ✅ YES | Full support |
| SQL Server | ✅ YES | Full support |
| MySQL InnoDB | ✅ YES | Since MySQL 4.0.14 |
| H2 | ✅ YES | Full support |
| MariaDB | ✅ YES | Full support |
| DB2 | ✅ YES | Full support |
package com.example.service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class BatchService {
@PersistenceContext
private EntityManager entityManager;
@Transactional(timeout = 300) // 5 minutes for batch operation
public void importUsers(List<UserDTO> dtos) {
int batchSize = 50; // Adjust based on entity size and complexity
for (int i = 0; i < dtos.size(); i++) {
User user = new User();
user.setName(dtos.get(i).getName());
user.setEmail(dtos.get(i).getEmail());
entityManager.persist(user);
if (i > 0 && i % batchSize == 0) {
entityManager.flush(); // REQUIRED: Write to DB
entityManager.clear(); // REQUIRED: Free memory
log.info("Processed {} of {} users", i, dtos.size());
}
}
// Flush remaining entities
entityManager.flush();
entityManager.clear();
log.info("Import completed: {} users", dtos.size());
}
@Transactional(timeout = 600) // 10 minutes for large batch
public BatchResult importUsersWithErrorHandling(List<UserDTO> dtos) {
int batchSize = 50;
int successCount = 0;
List<String> errors = new ArrayList<>();
for (int i = 0; i < dtos.size(); i++) {
try {
User user = new User();
user.setName(dtos.get(i).getName());
user.setEmail(dtos.get(i).getEmail());
entityManager.persist(user);
successCount++;
if (i > 0 && i % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
} catch (Exception e) {
errors.add("Row " + i + ": " + e.getMessage());
entityManager.clear(); // Clear after error
}
}
entityManager.flush();
return new BatchResult(successCount, errors);
}
}package com.example.service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class QueryOptimizationService {
@PersistenceContext
private EntityManager entityManager;
// ❌ BAD - N+1 query problem
public List<Order> findOrdersWithItemsBad() {
List<Order> orders = entityManager.createQuery(
"SELECT o FROM Order o", Order.class)
.getResultList(); // 1 query
for (Order order : orders) {
order.getItems().size(); // N queries (1 per order)
}
return orders;
// Total: 1 + N queries
}
// ✅ GOOD - Single query with JOIN FETCH
public List<Order> findOrdersWithItems() {
return entityManager.createQuery(
"SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items",
Order.class)
.getResultList();
// Total: 1 query
}
// ✅ GOOD - Multiple collections (avoid cartesian product)
public List<Order> findOrdersWithMultipleCollections(List<Long> ids) {
// Step 1: Fetch orders with items
List<Order> orders = entityManager.createQuery(
"SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.items " +
"WHERE o.id IN :ids",
Order.class)
.setParameter("ids", ids)
.getResultList();
// Step 2: Fetch payments separately (avoids cartesian product)
entityManager.createQuery(
"SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.payments " +
"WHERE o.id IN :ids",
Order.class)
.setParameter("ids", ids)
.getResultList();
return orders;
// Total: 2 queries (better than N+1, avoids cartesian product)
}
// ✅ GOOD - Using @EntityGraph (JPA 2.1+)
public List<Order> findOrdersWithEntityGraph() {
EntityGraph<Order> graph = entityManager.createEntityGraph(Order.class);
graph.addAttributeNodes("items", "customer");
return entityManager.createQuery("SELECT o FROM Order o", Order.class)
.setHint("jakarta.persistence.fetchgraph", graph)
.getResultList();
}
// ✅ GOOD - Batch fetching configuration
// In application.properties:
// spring.jpa.properties.hibernate.default_batch_fetch_size=10
public List<Order> findOrdersWithBatchFetching() {
List<Order> orders = entityManager.createQuery(
"SELECT o FROM Order o", Order.class)
.getResultList();
// Accessing items triggers batch fetching (10 at a time)
for (Order order : orders) {
order.getItems().size();
}
return orders;
// Total: 1 + ceil(N/10) queries (much better than N+1)
}
}Detection: Enable SQL logging to identify N+1 queries:
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
logging.level.org.hibernate.stat=DEBUGpackage com.example.service;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class UserService {
@PersistenceContext
private EntityManager entityManager;
public Page<User> findUsers(int page, int size) {
// Count query (no JOIN for better performance)
Long total = entityManager.createQuery(
"SELECT COUNT(u) FROM User u", Long.class)
.getSingleResult();
// Data query with pagination
List<User> users = entityManager.createQuery(
"SELECT u FROM User u ORDER BY u.createdDate DESC", User.class)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
return new PageImpl<>(users, PageRequest.of(page, size), total);
}
public Page<User> findUsersByStatus(String status, Pageable pageable) {
// Count query
Long total = entityManager.createQuery(
"SELECT COUNT(u) FROM User u WHERE u.status = :status", Long.class)
.setParameter("status", status)
.getSingleResult();
// Data query
List<User> users = entityManager.createQuery(
"SELECT u FROM User u WHERE u.status = :status ORDER BY u.createdDate DESC",
User.class)
.setParameter("status", status)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
return new PageImpl<>(users, pageable, total);
}
// Pagination with JOIN FETCH (careful with offset/limit)
public Page<User> findUsersWithOrders(int page, int size) {
// Count query (without JOIN FETCH)
Long total = entityManager.createQuery(
"SELECT COUNT(DISTINCT u) FROM User u", Long.class)
.getSingleResult();
// First: Get user IDs with pagination
List<Long> userIds = entityManager.createQuery(
"SELECT u.id FROM User u ORDER BY u.createdDate DESC", Long.class)
.setFirstResult(page * size)
.setMaxResults(size)
.getResultList();
// Then: Fetch users with orders using JOIN FETCH
List<User> users = entityManager.createQuery(
"SELECT DISTINCT u FROM User u " +
"LEFT JOIN FETCH u.orders " +
"WHERE u.id IN :ids " +
"ORDER BY u.createdDate DESC",
User.class)
.setParameter("ids", userIds)
.getResultList();
return new PageImpl<>(users, PageRequest.of(page, size), total);
}
}package com.example.service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ReportService {
@PersistenceContext
private EntityManager entityManager;
// ❌ Inefficient - loads all columns
public List<User> findUsersFull() {
return entityManager.createQuery("SELECT u FROM User u", User.class)
.getResultList();
}
// ✅ Efficient - DTO projection (loads only needed columns)
public List<UserSummaryDTO> findUsersSummary() {
return entityManager.createQuery(
"SELECT new com.example.dto.UserSummaryDTO(u.id, u.name, u.email) " +
"FROM User u",
UserSummaryDTO.class)
.getResultList();
}
// ✅ DTO with aggregate
public List<OrderSummaryDTO> findOrderSummaries() {
return entityManager.createQuery(
"SELECT new com.example.dto.OrderSummaryDTO(" +
" o.id, o.orderNumber, o.createdDate, " +
" o.customer.name, SIZE(o.items), SUM(oi.price * oi.quantity)" +
") " +
"FROM Order o " +
"LEFT JOIN o.items oi " +
"GROUP BY o.id, o.orderNumber, o.createdDate, o.customer.name",
OrderSummaryDTO.class)
.getResultList();
}
// ✅ Tuple projection (when DTO not needed)
public List<Tuple> findUserNamesAndEmails() {
return entityManager.createQuery(
"SELECT u.name, u.email FROM User u",
Tuple.class)
.getResultList();
}
}
// DTO class
package com.example.dto;
public class UserSummaryDTO {
private Long id;
private String name;
private String email;
// REQUIRED: Constructor matching JPQL query
public UserSummaryDTO(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// Getters (required for serialization)
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}package com.example.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
@Configuration
@EnableTransactionManagement
public class MultiDbConfig {
@Bean
@Primary
public PlatformTransactionManager primaryTransactionManager(
@Qualifier("primaryEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
@Bean
public PlatformTransactionManager secondaryTransactionManager(
@Qualifier("secondaryEntityManagerFactory") EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
// Usage
package com.example.service;
@Service
public class MultiDbService {
@PersistenceContext(unitName = "primary")
private EntityManager primaryEM;
@PersistenceContext(unitName = "secondary")
private EntityManager secondaryEM;
// Uses primary (default due to @Primary)
@Transactional
public void saveToPrimary(PrimaryEntity entity) {
primaryEM.persist(entity);
}
// Explicitly specify secondary
@Transactional("secondaryTransactionManager")
public void saveToSecondary(SecondaryEntity entity) {
secondaryEM.persist(entity);
}
// Alternative: use transactionManager attribute
@Transactional(transactionManager = "secondaryTransactionManager")
public void saveToSecondaryAlt(SecondaryEntity entity) {
secondaryEM.persist(entity);
}
// ❌ CRITICAL ERROR: Cannot mix transaction managers!
@Transactional("primaryTransactionManager")
public void cannotMixTransactions() {
primaryEM.persist(new PrimaryEntity()); // ✅ OK - in primary txn
secondaryEM.persist(new SecondaryEntity()); // ❌ WRONG - not in secondary txn!
}
// ✅ CORRECT: Separate methods with own transactions
@Transactional("primaryTransactionManager")
private void saveToPrimaryInternal(PrimaryEntity entity) {
primaryEM.persist(entity);
}
@Transactional("secondaryTransactionManager")
private void saveToSecondaryInternal(SecondaryEntity entity) {
secondaryEM.persist(entity);
}
// Non-transactional coordinator
public void saveToBoth(PrimaryEntity primary, SecondaryEntity secondary) {
saveToPrimaryInternal(primary);
saveToSecondaryInternal(secondary);
// Note: NOT atomic across both databases!
}
}ERROR: javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available
// ❌ WRONG - internal call bypasses transaction proxy
@Service
public class UserService {
@PersistenceContext
private EntityManager entityManager;
public void registerUser(String name, String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
this.saveUser(user); // NO transaction! (self-invocation)
}
@Transactional
public void saveUser(User user) {
entityManager.persist(user);
}
}
// ✅ SOLUTION 1: Move logic into transactional method
@Service
public class UserService {
@PersistenceContext
private EntityManager entityManager;
@Transactional
public void registerUser(String name, String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
entityManager.persist(user);
}
}
// ✅ SOLUTION 2: Inject self (Spring 4.3+)
@Service
public class UserService {
@PersistenceContext
private EntityManager entityManager;
@Autowired
private UserService self; // Injects proxy
public void registerUser(String name, String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
self.saveUser(user); // ✅ Transaction works!
}
@Transactional
public void saveUser(User user) {
entityManager.persist(user);
}
}
// ✅ SOLUTION 3: Use AopContext (requires aspectjweaver dependency)
import org.springframework.aop.framework.AopContext;
@Service
public class UserService {
@PersistenceContext
private EntityManager entityManager;
public void registerUser(String name, String email) {
User user = new User();
user.setName(name);
user.setEmail(email);
((UserService) AopContext.currentProxy()).saveUser(user);
}
@Transactional
public void saveUser(User user) {
entityManager.persist(user);
}
}
// Enable AspectJ in configuration
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
@EnableAspectJAutoProxy(exposeProxy = true)
public class AppConfig { }ISSUE: Transaction commits even though an exception occurred
// ❌ WRONG - transaction still commits!
@Transactional
public void updateUser(Long id, String name) {
try {
User user = entityManager.find(User.class, id);
user.setName(name);
} catch (Exception e) {
log.error("Error updating user", e); // Swallowed - transaction commits!
}
}
// ✅ SOLUTION 1: Rethrow exception
@Transactional
public void updateUser(Long id, String name) {
try {
User user = entityManager.find(User.class, id);
user.setName(name);
} catch (Exception e) {
log.error("Error updating user", e);
throw e; // ✅ Triggers rollback
}
}
// ✅ SOLUTION 2: Manual rollback (if you need to handle gracefully)
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@Transactional
public void updateUser(Long id, String name) {
try {
User user = entityManager.find(User.class, id);
user.setName(name);
} catch (Exception e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
log.error("Error updating user, transaction marked for rollback", e);
// Handle gracefully without throwing
}
}
// ✅ SOLUTION 3: Throw custom exception with noRollbackFor
@Transactional(noRollbackFor = ValidationException.class)
public void updateUserWithValidation(Long id, String name) {
if (name == null || name.trim().isEmpty()) {
throw new ValidationException("Name cannot be empty"); // No rollback
}
User user = entityManager.find(User.class, id);
user.setName(name);
// Any other exception triggers rollback
}ERROR: org.hibernate.LazyInitializationException: failed to lazily initialize a collection
// ❌ WRONG - accessing lazy association outside transaction
@Service
public class OrderService {
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public Order findById(Long id) {
return entityManager.find(Order.class, id);
} // ← Transaction ends here, EntityManager closes
}
@Controller
public class OrderController {
@Autowired
private OrderService orderService;
public String showOrder(Long id) {
Order order = orderService.findById(id);
// ❌ LazyInitializationException - items not loaded, transaction closed
order.getItems().size();
}
}
// ✅ SOLUTION 1: Use JOIN FETCH
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findByIdWithItems(Long id) {
return entityManager.createQuery(
"SELECT o FROM Order o " +
"LEFT JOIN FETCH o.items " +
"WHERE o.id = :id",
Order.class)
.setParameter("id", id)
.getSingleResult();
}
}
// ✅ SOLUTION 2: Initialize within transaction
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findById(Long id) {
Order order = entityManager.find(Order.class, id);
order.getItems().size(); // ✅ Force initialization within transaction
return order;
}
}
// ✅ SOLUTION 3: Use @EntityGraph (JPA 2.1+)
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findByIdWithEntityGraph(Long id) {
EntityGraph<Order> graph = entityManager.createEntityGraph(Order.class);
graph.addAttributeNodes("items", "customer");
return entityManager.find(Order.class, id,
Map.of("jakarta.persistence.fetchgraph", graph));
}
}
// ✅ SOLUTION 4: Use Hibernate.initialize()
import org.hibernate.Hibernate;
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order findById(Long id) {
Order order = entityManager.find(Order.class, id);
Hibernate.initialize(order.getItems()); // ✅ Explicit initialization
return order;
}
}
// ⚠️ SOLUTION 5: Open EntityManager in View (not recommended)
// In application.properties:
spring.jpa.open-in-view=true // Default in Spring Boot
// Keeps EntityManager open for entire HTTP request
// Downsides: Performance issues, hides data access problemsSYMPTOMS: Changes not persisted, no exception thrown
CHECKLIST:
| Check | How to Verify | Solution |
|---|---|---|
| @EnableTransactionManagement present? | Search configuration class | Add @EnableTransactionManagement |
| Method is public? | Check method visibility | Make method public |
| Not self-invocation? | Check if method calls itself | Use self-injection or extract method |
| JpaTransactionManager bean exists? | Check ApplicationContext | Add transaction manager bean |
| Exception not swallowed? | Check try-catch blocks | Rethrow exception or set rollback |
| Correct entity manager injection? | Verify @PersistenceContext | Use @PersistenceContext, not @Autowired |
Debug Logging:
# Transaction interceptor (shows transaction boundaries)
logging.level.org.springframework.transaction.interceptor=TRACE
# Transaction manager (shows commit/rollback)
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
# Hibernate transactions
logging.level.org.hibernate.engine.transaction.internal.TransactionImpl=DEBUGCAUSE: Concurrent modification detected
SOLUTION: Implement retry logic
import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.annotation.Backoff;
import jakarta.persistence.OptimisticLockException;
@Service
public class RetryableService {
@PersistenceContext
private EntityManager entityManager;
@Retryable(
value = OptimisticLockException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void updateWithRetry(Long id, String name) {
User user = entityManager.find(User.class, id);
user.setName(name);
}
// Manual retry logic
@Transactional
public void updateWithManualRetry(Long id, String name) {
int maxAttempts = 3;
int attempt = 0;
while (attempt < maxAttempts) {
try {
User user = entityManager.find(User.class, id);
user.setName(name);
entityManager.flush();
return; // Success
} catch (OptimisticLockException e) {
attempt++;
if (attempt >= maxAttempts) {
throw e; // Give up
}
entityManager.clear(); // Clear stale entity
try {
Thread.sleep(100 * attempt); // Exponential backoff
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
}
// Enable retry
@Configuration
@EnableRetry
public class RetryConfig { }CAUSE: Long-running operations within transaction
SOLUTION: Minimize transaction scope
// ❌ WRONG - external call holds transaction
@Transactional
public void processOrder(Order order) {
entityManager.persist(order);
externalApi.sendNotification(order); // ❌ Slow! Holds DB connection
}
// ✅ CORRECT - release transaction before external call
@Transactional
public void processOrder(Order order) {
entityManager.persist(order);
} // ← Transaction commits here
public void sendNotification(Order order) {
externalApi.sendNotification(order); // ✅ Outside transaction
}
// Call both methods
public void processAndNotify(Order order) {
processOrder(order); // Commits immediately
sendNotification(order); // No transaction
}
// ✅ ALTERNATIVE - Use propagation NOT_SUPPORTED for external calls
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void sendNotification(Order order) {
externalApi.sendNotification(order); // Suspends any existing transaction
}