or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

exception-translation.mdhibernate-configuration.mdhibernate-transaction-management.mdindex.mdjpa-configuration.mdjpa-transaction-management.mdjpa-vendor-adapters.mdpersistence-unit-management.mdshared-resources.mdutility-classes.mdweb-integration.md
tile.json

jpa-transaction-management.mddocs/

JPA Transaction Management

Transaction management for JPA EntityManager instances with Spring's declarative transaction infrastructure.

Quick Setup

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 Attributes Complete Reference

@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() { }

Propagation Behavior Decision Matrix

Quick Decision Guide

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 transaction

Propagation Behavior Matrix

Calling 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-transactionally

Template: Basic CRUD Service

package 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();
    }
}

Template: Read-Only Service

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);
    }
}

Template: REQUIRES_NEW (Independent Transaction)

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:

  • ✅ Audit logging that must persist on failure
  • ✅ Event recording for event sourcing
  • ✅ Statistics/metrics collection
  • ✅ Notification sending records
  • ✅ Idempotency tokens

Avoid REQUIRES_NEW for:

  • ❌ Regular business operations (causes deadlocks)
  • ❌ Operations that should roll back together
  • ❌ High-frequency calls (performance impact from transaction suspension)

Template: NESTED (Savepoint-Based)

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:

  • JDBC 3.0+ savepoint support
  • Database support for savepoints (PostgreSQL, Oracle, SQL Server, MySQL with InnoDB)
  • DataSourceTransactionManager or JpaTransactionManager (not JtaTransactionManager)

Savepoint Support by Database:

DatabaseSavepoint SupportNotes
PostgreSQL✅ YESFull support
Oracle✅ YESFull support
SQL Server✅ YESFull support
MySQL InnoDB✅ YESSince MySQL 4.0.14
H2✅ YESFull support
MariaDB✅ YESFull support
DB2✅ YESFull support

Template: Batch Processing (Memory-Safe)

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);
    }
}

Template: N+1 Query Prevention

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=DEBUG

Template: Pagination

package 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);
    }
}

Template: DTO Projection

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; }
}

Multiple Transaction Managers

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!
    }
}

Common Mistakes → Solutions

Mistake 1: Self-Invocation Bypasses Proxy

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 { }

Mistake 2: Exception Swallowing (Transaction Commits Despite Error)

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
}

Mistake 3: LazyInitializationException

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 problems

Troubleshooting

Problem: Transactions Not Working

SYMPTOMS: Changes not persisted, no exception thrown

CHECKLIST:

CheckHow to VerifySolution
@EnableTransactionManagement present?Search configuration classAdd @EnableTransactionManagement
Method is public?Check method visibilityMake method public
Not self-invocation?Check if method calls itselfUse self-injection or extract method
JpaTransactionManager bean exists?Check ApplicationContextAdd transaction manager bean
Exception not swallowed?Check try-catch blocksRethrow exception or set rollback
Correct entity manager injection?Verify @PersistenceContextUse @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=DEBUG

Problem: OptimisticLockException

CAUSE: 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 { }

Problem: Slow Transactions

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
}

Related Topics

  • JPA Configuration - EntityManagerFactory setup
  • Exception Translation - Exception handling
  • Utility Classes - EntityManagerFactoryUtils helpers