In Spring, @Transacional annotation is used for indicating a method run inside a database transaction. It can also be annotated on the class level which applies as a default to all methods of the declaring class and its subclasses

Say you have a crudAgainstDatabase method annotated with @Transacional as below

@Transactional
public void crudAgainstDatabase() {  
    readFromDatabase();
    writeToDatabase();
}

Spring AOP will create a proxy class and wraps the annotated method as the following pseudocode

Transaction tx = entityManager.getTransaction();

public void crudAgainstDatabaseWrapper() {  
    try {
        tx.createANewTransactionIfNecessary();

        crudAgainstDatabase();

        tx.commitTransaction();
    } catch(RuntimeException e) {
        tx.rollbackTransaction();
    }
}

The dependencies

To use @Transactional, include Spring Data JPA dependency into your project. In Spring Boot, you can include spring-boot-starter-data-jpa

<dependency>  
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

@Transactional isolation

You can declare which isolation level you'd like to use with this property. The options are DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE

Learn more about Transaction isolation levels

The default and most practical option is Isolation.DEFAULT which delegates the setting to the underlying database

This property only applies to the newly started transactions so it only works with Propagation.REQUIRED and Propagation.REQUIRED_NEW

The below example set the isolation level to READ_COMMITTED

@Transactional(isolation = Isolation.READ_COMMITTED)
public void crudAgainstDatabase() {  
    ...
}

@Transactional propagation

You can control either to use a current transaction to execute CRUD operations against a database or suspend it with the following options

  • Use a current transaction, if none exists then create a new one with Propagation.REQUIRED, execute non-transactionally with Propagation.SUPPORTS, and throw an exception with Propagation.MANDATORY

  • Suspend a current transaction, if none exists then create a new transaction with Propagation.REQUIRED_NEW, execute non-transactionally with Propagation.NOT_SUPPORTED

The default and most practical option is Propagation.REQUIRED

The below example set the isolation level to READ_COMMITTED and propagation to REQUIRED

@Transactional(
    isolation = Isolation.READ_COMMITTED, 
    propagation = Propagation.REQUIRED)
public void crudAgainstDatabase() {  
    ...
}

@Transactional rollbackFor and noRollbackFor

  • You can use rollbackFor to indicate which exception types must cause a transaction rollback. By default, they are unchecked exceptions including RuntimeException, Error and their subclasses

Say you throw a RuntimeException in your custom Transactional method

@Transactional
public void updateWithThrowingRuntimeException(Long id, String name) {  
    Product product = findById(id).get();
    product.setName(name);
    throw new MyRuntimeException();
}

static class MyRuntimeException extends RuntimeException {  
}

Then the transaction is rollbacked by the throwing MyRuntimeException as expected

@Test
public void testRollbackRuntimeException() {  
    try {
        productService.updateWithThrowingRuntimeException(1L, "updated");
    } catch (RuntimeException e) {
        System.out.println(e.getMessage());
    }

    Optional<Product> updatedProduct = productService.findByName("updated");
    assertThat(updatedProduct).isNotPresent();
}
  • Checked exceptions, Exception and its subclasses, don't cause transaction rollback by default

Say you throw an Exception in your custom Transactional method

@Transactional
public void updateWithThrowingException(Long id, String name) throws Exception {  
    Product product = findById(id).get();
    product.setName(name);
    throw new MyException();
}

static class MyException extends Exception {  
}

Then the transaction is not rollbacked by the throwing MyException as expected

@Test
public void testRollbackException() {  
    try {
        productService.updateWithThrowingException(1L, "updated");
    } catch (Exception e) {
        System.out.println(e.getMessage());
    }

    Optional<Product> updatedProduct = productService.findByName("updated");
    assertThat(updatedProduct).isPresent();
}
  • Use noRollbackFor to indicate which exception types must not cause a transaction rollback

Say you throw a MyException in your custom @Transactional(noRollbackFor = MyException.class) method

@Transactional(noRollbackFor = MyException.class)
public void updateWithNoRollbackFor(Long id, String name) throws Exception {  
    Product product = findById(id).get();
    product.setName(name);
    throw new MyException();
}

Then the transaction is not rollbacked by the throwing MyException as expected

@Test
public void testNoRollbackFor() {  
    try {
        productService.updateWithNoRollbackFor(1L, "updated");
    } catch (Exception e) {
        System.out.println(e.getMessage());
    }

    Optional<Product> updatedProduct = productService.findByName("updated");
    assertThat(updatedProduct).isPresent();
}

@Transactional readOnly

A boolean flag served as a hint for the actual transaction subsystems

The default value is false. In Spring Data JPA and MySQL, an exception will be thrown when persist or delete operations against a database is called inside a method annotated with @Transactional(readOnly=true)

java.sql.SQLException: Connection is read-only.  
    Queries leading to data modification are not allowed

readOnly=true can trigger a few performance optimizations in the underlying entity manager such as skipping dirty checks when closing the EntityManager via setting the FlushMode to MANUAL

@Transactional pitfalls

  • You don't have to call save/update method against database explicitly inside a @Transactional method

Say you have a Transactional method in ProductService class

@Transactional
public void updateImplicitly(Long id, String name) {  
    Product product = findById(id).get();
    product.setName(name);

    // productRespository.save(product);
}

The update is still successfully hit the database

@Test
public void testUpdateImplicitly() {  
    productService.updateImplicitly(1L, "updated");

    Optional<Product> updatedProduct = productService.findByName("updated");
    assertThat(updatedProduct).isPresent();
}

There are no redundant JPA calls to the database. However, you may encounter an issue like the following example which might not work as expected

@Transactional
public void updateOnCondition(Long id, String name) {  
    Product product = findById(id).get();
    product.setName(name);

    if (product.getPrice().compareTo(new BigDecimal("10")) == 0) {
        productRespository.save(product);
    }
}
  • As the limitation of Spring AOP, @Transactional can not work with non-public methods and in the same class method call (self-invocation)

Say you have a non-public Transactional method defined in a Service class

@Transactional
void updateImplicitlyNonPublic(Long id, String name) {  
    Product product = findById(id).get();
    product.setName(name);
}

Then the update does not hit the database as expected

@Test
public void testUpdateImplicitlyNonPublic() {  
    productService.updateImplicitlyNonPublic(1L, "updated");

    Optional<Product> updatedProduct = productService.findByName("updated");
    assertThat(updatedProduct).isNotPresent();
}

Conclusion

In this tutorial, we learned how to use @Transactional in Spring Data JPA through various practical examples. You can find the full source code as below

[TransactionalProductService.java]

import lombok.RequiredArgsConstructor;  
import org.springframework.stereotype.Service;  
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;  
import java.util.Optional;

@RequiredArgsConstructor
@Service
public class TransactionalProductService {  
    private final ProductRespository productRespository;

    public Optional<Product> findById(Long id) {
        return productRespository.findById(id);
    }

    public Optional<Product> findByName(String name) {
        return productRespository.findByName(name);
    }

    public Product save(Product stock) {
        return productRespository.save(stock);
    }

    @Transactional
    public void updateImplicitly(Long id, String name) {
        Product product = findById(id).get();
        product.setName(name);

        // productRespository.save(product);
    }

    @Transactional
    public void updateOnCondition(Long id, String name) {
        Product product = findById(id).get();
        product.setName(name);

        if (product.getPrice().compareTo(new BigDecimal("10")) == 0) {
            productRespository.save(product);
        }
    }

    @Transactional
    void updateImplicitlyNonPublic(Long id, String name) {
        Product product = findById(id).get();
        product.setName(name);
    }

    @Transactional
    public void updateWithThrowingRuntimeException(Long id, String name) {
        Product product = findById(id).get();
        product.setName(name);
        throw new MyRuntimeException();
    }

    static class MyRuntimeException extends RuntimeException {
    }

    @Transactional
    public void updateWithThrowingException(Long id, String name) throws Exception {
        Product product = findById(id).get();
        product.setName(name);
        throw new MyException();
    }

    static class MyException extends Exception {
    }

    @Transactional(noRollbackFor = MyException.class)
    public void updateWithNoRollbackFor(Long id, String name) throws Exception {
        Product product = findById(id).get();
        product.setName(name);
        throw new MyException();
    }
}

[TransactionalTest.java]

import org.junit.Before;  
import org.junit.Test;  
import org.junit.runner.RunWith;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.test.context.junit4.SpringRunner;

import java.math.BigDecimal;  
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class TransactionalTest {  
    @Autowired
    private TransactionalProductService productService;

    @Before
    public void setUp(){
        // given
        Product product = Product.builder()
            .name("P1")
            .description("P1 desc")
            .price(new BigDecimal("1"))
            .build();

        productService.save(product);
    }

    @Test
    public void testUpdateImplicitly() {
        productService.updateImplicitly(1L, "updated");

        Optional<Product> updatedProduct = productService.findByName("updated");
        assertThat(updatedProduct).isPresent();
    }

    @Test
    public void testUpdateOnCondition() {
        productService.updateOnCondition(1L, "updated");

        Optional<Product> updatedProduct = productService.findByName("updated");
        assertThat(updatedProduct).isPresent();
    }

    @Test
    public void testUpdateImplicitlyNonPublic() {
        productService.updateImplicitlyNonPublic(1L, "updated");

        Optional<Product> updatedProduct = productService.findByName("updated");
        assertThat(updatedProduct).isNotPresent();
    }

    @Test
    public void testRollbackRuntimeException() {
        try {
            productService.updateWithThrowingRuntimeException(1L, "updated");
        } catch (RuntimeException e) {
            System.out.println(e.getMessage());
        }

        Optional<Product> updatedProduct = productService.findByName("updated");
        assertThat(updatedProduct).isNotPresent();
    }

    @Test
    public void testRollbackException() {
        try {
            productService.updateWithThrowingException(1L, "updated");
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }

        Optional<Product> updatedProduct = productService.findByName("updated");
        assertThat(updatedProduct).isPresent();
    }

    @Test
    public void testNoRollbackFor() {
        try {
            productService.updateWithNoRollbackFor(1L, "updated");
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }

        Optional<Product> updatedProduct = productService.findByName("updated");
        assertThat(updatedProduct).isPresent();
    }
}