10 Fatal @Transactional Mistakes in Spring Boot (The Last One Is Critical)

This article examines ten common pitfalls when using Spring Boot's @Transactional annotation, explains why each issue occurs—from self‑invocation and private methods to swallowed exceptions, read‑only settings, long transactions, lock interactions, and deadlocks—and provides concrete code examples and three‑step solutions to avoid them.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
10 Fatal @Transactional Mistakes in Spring Boot (The Last One Is Critical)

1. Introduction

In Spring Boot 3.5.0, @Transactional declares the transaction boundaries of a method. Incorrect usage can cause lost rollbacks, deadlocks, or performance problems. The following ten typical error scenarios are examined with concrete code examples and fixes.

2. Error scenarios and fixes

2.1 Self‑invocation

Calling a @Transactional method from another method of the same bean bypasses the Spring proxy, so the transaction never starts.

@Service
public class OrderService {
    public void processOrder(Order order) {
        validateOrder(order);
        // transaction will not be applied here
        saveOrder(order);
        sendEmail(order);
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
        itemRepository.saveAll(order.getItems());
    }
}

Three ways to make the call go through the proxy:

Inject the bean itself :

@Service
public class OrderService {
    @Resource
    private OrderService self;

    public void processOrder(Order order) {
        self.saveOrder(order);
    }
    // ... saveOrder as above
}

Use AopContext.currentProxy() :

public void processOrder(Order order) {
    OrderService proxy = (OrderService) AopContext.currentProxy();
    proxy.saveOrder(order);
}

Move the transactional method to another bean and invoke it.

2.2 @Transactional on private methods

Spring’s proxy can intercept only public methods. Private (and package‑private) methods are invisible, so the annotation has no effect. From Spring 6.x onward, protected methods become proxy‑visible.

2.3 Swallowed exceptions

If a @Transactional method catches an exception without rethrowing it, the transaction manager cannot detect the failure and will not roll back.

@Transactional
public void importUsers(File file) {
    try {
        List<User> users = readFile(file);
        userRepository.saveAll(users);
    } catch (Exception e) {
        log.error("Error importing users", e); // no rethrow
    }
}

Fix by rethrowing (or wrapping) the exception after logging:

@Transactional
public void importUsers(File file) {
    try {
        List<User> users = readFile(file);
        userRepository.saveAll(users);
    } catch (Exception e) {
        log.error("Error importing users", e);
        throw new RuntimeException(e);
    }
}

2.4 Checked exceptions do not trigger rollback

By default @Transactional rolls back only on unchecked exceptions and Error. A checked IOException will not cause a rollback.

@Transactional
public void processFile(String path) throws IOException {
    File file = new File(path);
    // ...
}

Solutions:

Specify the checked exception in rollbackFor.

@Transactional(rollbackFor = {IOException.class})
public void processFile(String path) throws IOException {
    // ...
}

Enable a global policy (available from Spring 6.2):

@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
public class AppConfig { }

2.5 Propagation.REQUIRES_NEW starts a new transaction

When a method annotated with propagation = Propagation.REQUIRES_NEW is called, the current transaction is suspended and a new one is created. The inner transaction commits even if the outer transaction later rolls back.

@Transactional
public void processPayment(Order order) {
    paymentService.logAttempt(order); // new transaction
    chargeCard(order);
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAttempt(Order order) {
    logRepository.save(new AttemptLog(order));
}

2.6 Transaction marked as read‑only

Applying @Transactional(readOnly = true) to a write operation prevents data modification.

@Transactional(readOnly = true)
public void updateUser(User user) {
    userRepository.save(user); // will fail because the transaction is read‑only
}

2.7 Long‑running transactions affect performance

A transaction that holds a remote call (e.g., a risk‑check service) keeps the database connection occupied, risking pool exhaustion.

@Transactional
public void deductProduct(Long productId, Integer deductStock) {
    riskServiceClient.check(); // remote call blocks transaction
    Product product = productRepository.findById(productId)
        .orElseThrow(() -> new RuntimeException("Product not found"));
    if (product.getStock() < deductStock) {
        throw new RuntimeException("Insufficient stock");
    }
    productRepository.updateStock(deductStock, productId);
}

Remedies:

Use programmatic transaction (e.g., TransactionTemplate) and keep the remote call outside the transaction.

Move the remote call to a separate method that runs before the transactional method.

2.8 Transaction combined with synchronized lock

When a synchronized method is also @Transactional, the lock is taken on the target object, not the proxy, which can hide concurrency bugs.

@Transactional
public synchronized void deductProduct(Long productId, Integer deductStock) {
    // business logic
}

Possible solutions:

Pessimistic lock : fetch the entity with @Lock(LockModeType.PESSIMISTIC_WRITE) and then update.

Programmatic transaction : obtain the current proxy via AopContext.currentProxy() and invoke the locked method.

Database‑level condition : let the UPDATE statement include a stock‑check clause (see 2.10).

2.9 Deadlock scenario

When a transactional method is synchronized and performs a database write, different threads may acquire the JVM lock in the opposite order of the database lock, producing MySQL deadlock errors.

@Transactional
public synchronized void deductProduct(Long productId, Integer deductStock) {
    Product product = productRepository.findById(productId)
        .orElseThrow(() -> new RuntimeException("Product not found"));
    if (product.getStock() < deductStock) {
        throw new RuntimeException("Insufficient stock");
    }
    productRepository.updateStock(deductStock, productId);
    // log saving in REQUIRES_NEW transaction
}

Fixes include removing the REQUIRES_NEW propagation from the log‑saving method or, with caution, dropping the foreign‑key constraint.

2.10 Transaction, lock and safe update

To avoid the deadlock shown above, three concrete alternatives are demonstrated.

Pessimistic lock with a separate service that obtains the proxy and calls a locked method:

@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;

    public void deductStock(Long productId, Integer quantity) {
        ProductService proxy = (ProductService) AopContext.currentProxy();
        proxy.deductProduct(productId, quantity);
    }

    @Transactional
    public void deductProduct(Long productId, Integer deductStock) {
        Product product = productRepository.findByIdWithLock(productId)
            .orElseThrow(() -> new RuntimeException("Product not found"));
        if (product.getStock() < deductStock) {
            throw new RuntimeException("Insufficient stock");
        }
        int updated = productRepository.updateStock(deductStock, productId);
        if (updated == 0) {
            throw new RuntimeException("Stock update failed");
        }
    }
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);

    @Modifying
    @Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id")
    int updateStock(@Param("quantity") Integer quantity, @Param("id") Long id);
}

Programmatic transaction with an explicit lock object:

private final Object lock = new Object();

public void deductProduct(Long productId, Integer deductStock) {
    riskServiceClient.check();
    synchronized (lock) {
        transactionTemplate.executeWithoutResult(status -> {
            Product product = productRepository.findById(productId)
                .orElseThrow(() -> new RuntimeException("Product not found"));
            if (product.getStock() < deductStock) {
                throw new RuntimeException("Insufficient stock");
            }
            productRepository.updateStock(deductStock, productId);
        });
    }
}

Database‑level guard condition that lets the UPDATE fail when stock is insufficient:

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Modifying
    @Query("UPDATE Product p SET p.stock = p.stock - :deductStock " +
           "WHERE p.id = :id AND p.stock >= :deductStock")
    int updateStock(@Param("deductStock") Integer deductStock,
                    @Param("id") Long id);
}

3. Conclusion

Understanding Spring’s proxy mechanism, transaction propagation, exception handling, and interaction with synchronization primitives is essential to avoid the ten fatal @Transactional mistakes. Applying the demonstrated code patterns yields reliable transaction management in Spring Boot 3.5.0 applications.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaBackend DevelopmentSpring Boot@TransactionalTransaction Management
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.