Spring @Transactional Rollback Handling with Code Explanation

Wynn TeoWynn Teo
8 min read

Bydefault, Spring Boot transactions are auto-committed. This means that every single SQL statement is treated as its transaction and will commit after execution. Here’s an example that illustrates this behaviour:

public void createProduct() {  
    Product prod = new Product();
    prod.setDescription("This is an example with runtime exception but no rollback.");
    prod.setPrice(10);
    prod.setTitle("First Product");
    productRepository.save(prod);
    throw new RuntimeException();
}

In this example, the product is inserted into the database even though an exception has been raised. This default behaviour might not be suitable for scenarios where multiple transactions are run in a logical unit of code.

In Spring Boot, when @Transactional annotation is used, Spring Boot implicitly creates a proxy that will be creating a connection to the database. A transaction will be started and committed after the code has been executed errorless. Otherwise, it will roll back the changes if an exception occurred.

import java.sql.Connection;

Connection conn = dataSource.getConnection(); 

try (connection) {
    // execute some SQL statements...
    // commit transaction
    conn.commit();

} catch (SQLException e) {
    conn.rollback();
}

Example 1: Basic Usage of @Transactional

The @Transactional annotation in Spring Boot implicitly creates a proxy that starts a transaction and commits it if no errors occur. If an exception occurs, it rolls back the changes.

@Transactional
public void createProduct() {  
    Product prod = new Product();
    prod.setDescription("This is an example with runtime exception and transactional annotation.");
    prod.setPrice(10);
    prod.setTitle("Second Product");
    productRepository.save(prod);
    throw new RuntimeException();
}

The @Transactional annotation ensures that the transaction is rolled back if an exception occurs. Note that Spring only rolls back on unchecked exceptions by default.

Example 2a: Checked Exception Without Rollback

In this example, the checked exception will not be rolled back even though the @Transactional annotation is used. This is because SQLException is a checked exceptions.

@Transactional
public void createProduct() throws Exception {  
    Product prod = new Product();
    prod.setDescription("This is an example with checked exception and transactional annotation.");
    prod.setPrice(10);
    prod.setTitle("Second Product");
    productRepository.save(prod);
    throw new SQLException();
}

Example 2b: Using rollbackFor to Roll Back for Specific Exceptions

To roll back checked exceptions, the rollbackFor attribute can be specified.

@Transactional(rollbackFor = SQLException.class)
public void createProduct() throws Exception {  
    Product prod = new Product();
    prod.setDescription("This is an example with checked exception and transactional annotation with rollbackFor.");
    prod.setPrice(10);
    prod.setTitle("Example 2b Product");
    productRepository.save(prod);
    throw new SQLException();
}

We see that the transaction has rolled back successfully.

Example 2c: Using noRollbackFor to Prevent Rollback for Specific Exceptions

Conversely, you may want to roll back for all exceptions except specific ones. You can use the noRollbackFor attribute to achieve this.

@Transactional(noRollbackFor = NonCriticalException.class)
public void processPayment() throws Exception {
    // Some code that may throw NonCriticalException or CriticalException
    if (someCondition) {
        throw new NonCriticalException("Don't roll back for this exception!");
    } else {
        throw new CriticalException("Roll back this transaction!");
    }
}

In this example, if NonCriticalException is thrown, the transaction will not be rolled back. If any other exception (RuntimeException), such as CriticalException, is thrown, the transaction will be rolled back.

Example 3: Using Try-Catch Block Inside @Transactional

In this example, we’ll explore what happens when a try-catch block is used inside a method annotated with @Transactional.

@Transactional
public void createProduct() {
    try {
        System.out.println("------ createProduct ------");
        Product prod = new Product();
        prod.setDescription("This is an example with runtime exception, transactional annotation and try catch.");
        prod.setPrice(10);
        prod.setTitle("Example 3 Product");
        productRepository.save(prod);
        System.out.println("Example 3 Product inserted.");
        throw new RuntimeException();
    } catch (Exception e) {
        System.out.println("Here we catch the exception.");
    }
}

In this scenario, the try-catch block inside the method catches the RuntimeException. Since the exception is caught and handled within the method, the transaction is not rolled back. Instead, it’s executed normally and committed.

Example 4: Nested Transactions with Rollback

Spring transactions can be nested, and the behavior of nested transactions can be controlled using propagation settings.

In this example, both createProduct() and createOrder() methods are annotated with @Transactional. The createProduct() method calls the createOrder() method, and a RuntimeException is thrown inside createOrder().

//ProductController.java
@Transactional
public void createProduct() {
    Product prod = new Product();
    prod.setDescription("This is createProduct method.");
    prod.setPrice(10);
    prod.setTitle("Create Product");
    productRepository.save(prod);
    orderController.createOrder();
}

//OrderController.java
@Transactional
public void createOrder() {
    Order order = new Order();
    order.setTitle("Create Order");
    order.setDescription("This is createOrder method with runtime exception");
    orderRepository.save(order);  
    throw new RuntimeException("Create Order RuntimeException");
}

In this example, both createProduct() and createOrder() transactions are rolled back if a runtime exception occurs in the createOrder() method.

  1. Propagation Behavior: By default, the @Transactional annotation uses a propagation behavior called Propagation.REQUIRED. This means that if there is an active transaction, Spring will join that transaction instead of creating a new one. In this example, when createOrder() is called from createProduct(), it joins the existing transaction started by createProduct().

  2. Exception Handling: Since a RuntimeException is thrown inside createOrder(), and it's not caught within the method, the exception propagates to the calling method (createProduct()). Since the exception is not handled, the entire transaction is marked for rollback.

  3. Single Logical Transaction: Because both methods are part of the same logical transaction (due to the default propagation behavior), if one part of the transaction fails, the entire transaction is rolled back. This ensures the atomicity of the transaction, meaning that all changes are either committed or rolled back together.

Example 5: Handling RuntimeException with Try-Catch in Nested Transactions

In this example, the createProduct() method calls the createOrder() method. Inside createOrder(), a RuntimeException is thrown. The createProduct() method catches this exception using a try-and-catch block.

//ProductController.java
@Transactional
public void createProduct() {
    System.out.println("------ createProduct ------");
    Product prod = new Product();
    prod.setDescription("This is createProduct method.");
    prod.setPrice(10);
    prod.setTitle("Create Product");
    productRepository.save(prod);
    try {
        orderController.createOrder();
    } catch (RuntimeException e) {
        System.out.println("Handle " + e.getMessage());
    }
}

//OrderController.java
@Transactional
public void createOrder() {
    System.out.println("------ createOrder ------");
    Order order = new Order();
    order.setTitle("Create Order");
    order.setDescription("This is createOrder method with runtime exception");
    orderRepository.save(order);  
    throw new RuntimeException("Create Order RuntimeException");
}

Arg? Both records have been rollback. Why? At the same time, we also noticed the exception error.

org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

Why Are Both Records Rolled Back?

  1. Propagation Behavior: As in the previous examples, the default propagation behavior is Propagation.REQUIRED. This means that both methods are part of the same logical transaction. Spring will start and end the transaction according to the propagation setting

  2. Exception Handling: Even though the createProduct() method catches the RuntimeException, the transaction is already marked for rollback by the time the exception is caught. This is because the @Transactional annotation works at the proxy level, and the rollback decision is made before the catch block is executed.

  3. Transaction Silently Rolled Back: The exception org.springframework.transaction.UnexpectedRollbackException indicates that the transaction was silently rolled back because it was marked as rollback-only. This means that something within the transaction boundary has called for a rollback, and the transaction system is enforcing that decision.

  4. Atomicity: The behaviour ensures the atomicity of the transaction. If one part of the transaction fails, the entire transaction is rolled back, maintaining the consistency of the system.

Example 6: Using Propagation.REQUIRES_NEW

Using Propagation.REQUIRES_NEW forces Spring to create a subtransaction, allowing the outer transaction to commit even if the inner transaction rolls back.

@Transactional
public void createProduct() {
    Product prod = new Product();
    prod.setDescription("This is createProduct method.");
    prod.setPrice(10);
    prod.setTitle("Create Product");
    productRepository.save(prod);
    try {
        orderController.createOrder();
    } catch (RuntimeException e) {
        System.out.println("Handle " + e.getMessage());
    }
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createOrder() {
    Order order = new Order();
    order.setTitle("Create Order");
    order.setDescription("This is createOrder method with runtime exception");
    orderRepository.save(order);  
    throw new RuntimeException("Create Order RuntimeException");
}

The product record was written into the database, and the order record was rolled back.

Example 7: Exception in Inner Method with Propagation.REQUIRES_NEW

What if we never use try and catch block in the outer method, and an exception has happened in the inner method? Will the outer transaction still commit?

@Transactional
public void createProduct() {
    System.out.println("------ createProduct ------");
    Product prod = new Product();
    prod.setDescription("This is createProduct method.");
    prod.setPrice(10);
    prod.setTitle("Create Product");
    productRepository.save(prod);
    orderController.createOrder();
}

@Transactional(propagation=Propagation.REQUIRES_NEW)
public void createOrder() {
    System.out.println("------ createOrder ------");
    Order order = new Order();
    order.setTitle("Create Order");
    order.setDescription("This is createOrder method with runtime exception");
    orderRepository.save(order);  
    throw new RuntimeException("Create Order RuntimeException");
}

In this example, the createOrder() method is annotated with Propagation.REQUIRES_NEW, which means it will start a new transaction separate from the existing one. However, when an exception is thrown in the inner method (createOrder()), it is not caught, and both transactions are rolled back.

  1. New Transaction: The Propagation.REQUIRES_NEW setting starts a new transaction for createOrder(), but since the exception is not handled, it marks the transaction for rollback.

  2. Outer Transaction Affected: The unhandled exception in the inner transaction also affects the outer transaction (createProduct()), causing it to be rolled back as well.

Example 8: Exception in Outer Method with Propagation.REQUIRES_NEW

In this example, the exception is thrown in the outer method (createProduct()), after the inner method (createOrder()) has completed successfully.

@Transactional
public void createProduct() {
    System.out.println("------ createProduct ------");
    Product prod = new Product();
    prod.setDescription("This is createProduct method.");
    prod.setPrice(10);
    prod.setTitle("Create Product with runtime");
    productRepository.save(prod);
    orderController.createOrder();
    throw new RuntimeException("Create Product RuntimeException");
}

@Transactional(propagation=Propagation.REQUIRES_NEW)
public void createOrder() {
    System.out.println("------ createOrder ------");
    Order order = new Order();
    order.setTitle("Create Order with propagation required_new");
    order.setDescription("This is createOrder method.");
    orderRepository.save(order);  
}

  1. New Transaction for Inner Method: The Propagation.REQUIRES_NEW setting starts a new transaction for createOrder(), and since no exception is thrown in this method, the transaction is committed successfully.

  2. Exception in Outer Method: The exception in createProduct() causes the outer transaction to be rolled back, but it does not affect the inner transaction, which has already been committed.

Additional Notes

  • However, it’s essential to note that if both methods are in the same class, the @Transactional annotation will not create a new transaction, even with Propagation.REQUIRES_NEW. This is because the internal method call will bypass the proxy created by Spring, and the propagation setting will not take effect.

Thanks for reading! If you have any more questions, feel free to ask. Cheers! 🍸

0
Subscribe to my newsletter

Read articles from Wynn Teo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Wynn Teo
Wynn Teo

Passionate full-stack and AWS-certified engineer with 9+ years of hands-on experience developing websites and applications.