spring transaction management

Spring Transaction Management Rollback 2025

Understanding Spring Transaction Management

By default, without any transaction management, Spring operates in auto-commit mode. Every single SQL statement executes in its own transaction and commits immediately after execution. This behavior can lead to data inconsistency issues when multiple related operations need to be treated as a single atomic unit.

The Problem with Auto-Commit

Consider this basic example without spring transaction management:

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

Problem: The product gets inserted into the database even though an exception occurs afterward.

How @Transactional Annotation Works

The @Transactional annotation in Spring implicitly creates a proxy that initiates a transaction and commits it if no errors occur. When an exception arises, it ensures that the changes are rolled back. In this example, the transaction is rolled back when a RuntimeException occurs, maintaining data consistency:

// What Spring does internally:
Connection conn = dataSource.getConnection(); 
try (connection) {
    // Execute your business logic
    conn.commit();
} catch (Exception e) {
    conn.rollback();
}

What happens behind the scenes:

  1. Spring creates a transaction proxy around your method
  2. A database connection is obtained and auto-commit is disabled
  3. Your business logic executes within the transaction boundary
  4. When RuntimeException is thrown, Spring catches it and calls connection.rollback()
  5. All database changes within the transaction are undone

1. RuntimeException Triggers Automatic Rollback

When a RuntimeException is thrown within a transactional method, Spring automatically rolls back all database changes made within that transaction boundary:

@Service
public class ProductService {
    
    @Transactional
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        throw new RuntimeException("Business validation failed");
    }
}

Product is NOT saved to database. Spring automatically rolls back the transaction when RuntimeException is thrown, undoing all database operations within the transaction boundary.

2. Checked Exceptions Don’t Rollback by Default

Spring treats checked exceptions as expected business scenarios that shouldn’t invalidate the entire transaction. Only unchecked exceptions trigger automatic rollback.

@Service
public class ProductService {
    
    @Transactional
    public void createProduct() throws SQLException {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        throw new SQLException("Connection timeout");
    }
}

Product is saved to database. Spring does not rollback on checked exceptions by default. The SQLException is propagated to the caller, but the transaction commits first.

3. Force Rollback on Checked Exceptions

However, we can explicitly specify which exceptions should trigger rollback using the rollbackFor attribute:

@Service
public class ProductService {
    
    @Transactional(rollbackFor = SQLException.class)
    public void createProduct() throws SQLException {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        throw new SQLException("Connection timeout");
    }
}

In this case, Product is NOT saved to database. The rollbackFor attribute explicitly tells Spring to rollback when SQLException occurs, overriding the default behavior for checked exceptions.

4. Force No Rollback on Unchecked Exceptions

Conversely, we may want to roll back for all unchecked exceptions except specific ones. Use the noRollbackFor attribute to achieve this:

@Service
public class ProductService {
    
    @Transactional(noRollbackFor = RuntimeException.class)
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        throw new RuntimeException("Business validation failed");
    }
}

In this example, the product is saved to database.

Exception Types and Rollback Behavior

When we catch and handle exceptions within transactional methods, Spring never sees the exception and treats the method as completing successfully, preventing automatic rollback.

1. Try-Catch Blocks Prevent Rollback

Exception handling can accidentally prevent necessary rollbacks when exceptions are caught and not re-thrown:

@Service
public class ProductService {
    
    @Transactional
    public void createProduct() {
        try {
            Product product = new Product();
            product.setTitle("Test Product");
            product.setPrice(100);
            
            productRepository.save(product);
            
            performRiskyOperation();
            
        } catch (Exception e) {
            logger.error("Exception caught: " + e.getMessage());
        }
    }
    
    private void performRiskyOperation() {
        throw new RuntimeException("Critical failure");
    }
}

In this case, the product is saved to database. The exception is caught and handled, so Spring never sees the exception. From Spring’s perspective, the method completed successfully, so the transaction commits.

2. Manual Rollback Control

We can manually mark a transaction for rollback even when handling exceptions using setRollbackOnly().

@Service
public class ProductService {
    
    @Transactional
    public void createProductWithManualRollback() {
        try {
            Product product = new Product();
            product.setTitle("Test Product");
            product.setPrice(100);
            
            productRepository.save(product);
            
            performRiskyOperation();
            
        } catch (Exception e) {
            logger.error("Exception occurred: " + e.getMessage());
            
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            
            throw e;
        }
    }
}

Product is NOT saved to database. Even though the exception is caught, setRollbackOnly() manually marks the transaction for rollback. The re-thrown exception ensures the rollback is triggered.

3. Custom Rollback Rules

We can define specific exceptions that should or should not trigger rollback using rollbackFor and noRollbackFor attributes:

@Service
public class ProductService {
    
    @Transactional(
        rollbackFor = {SQLException.class, CustomBusinessException.class},
        noRollbackFor = {DataIntegrityViolationException.class}
    )
    public void createProductWithCustomRules() throws SQLException {
        Product product = new Product();
        product.setTitle("Custom Rollback Rules");
        product.setPrice(100);
        
        productRepository.save(product);
        
        if (someBusinessCondition()) {
            throw new SQLException("Database error");
        }
    }
}

Behavior depends on which exception is thrown. SQLException and CustomBusinessException will trigger rollback (product NOT saved), while DataIntegrityViolationException will not trigger rollback (product is saved).

Transaction Propagation and Nested Rollbacks

Transaction propagation defines how transactions behave when one transactional method calls another transactional method. 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.

1. Nested Transaction

With default REQUIRED propagation, all methods share the same transaction context, so any failure affects the entire transaction.

@Service
public class ProductService {
    
    @Autowired
    private OrderService orderService;
    
    @Transactional
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        orderService.createOrder();
    }
}

@Service
public class OrderService {
    
    @Transactional
    public void createOrder() {
        Order order = new Order();
        order.setTitle("Test Order");
        
        orderRepository.save(order);
        
        throw new RuntimeException("Order processing failed");
    }
}

Both Product and Order are NOT saved to database. With REQUIRED propagation (default), both methods share the same transaction.

Since a RuntimeException is thrown inside createOrder(), and it’s not caught within the method, the exception propagates to the calling method createProduct(). The exception is not handled by outer method as well, so the entire transaction is marked as rollback-only.

This ensures the atomicity of the transaction, meaning that all changes are either committed or rolled back together.

2. Exception Handling in Nested Transactions

Let’s add in the try-and-catch block to handle the runtime exception in the createProduct() method.

@Service
public class ProductService {
    
    @Autowired
    private OrderService orderService;
    
    @Transactional
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        try {
            orderService.createOrder();
        } catch (RuntimeException e) {
            logger.error("Order creation failed: " + e.getMessage());
        }
    }
}

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

Even though the exception is caught, the shared transaction is already marked as rollback-only by the inner method. Spring throws UnexpectedRollbackException because rollback-only transactions cannot commit.

3. Independent Transactions (REQUIRES_NEW) with Exception Handling

Using REQUIRES_NEW propagation creates independent transactions that can commit or rollback separately:

@Service
public class ProductService {
    
    @Autowired
    private OrderService orderService;
    
    @Transactional
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        try {
            orderService.createOrder();
        } catch (RuntimeException e) {
            logger.error("Order creation failed: " + e.getMessage());
        }
    }
}

@Service
public class OrderService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createOrder() {
        Order order = new Order();
        order.setTitle("Independent Order");
        
        orderRepository.save(order);
        
        throw new RuntimeException("Order processing failed");
    }
}

Using Propagation.REQUIRES_NEW forces Spring to create a subtransaction, allowing the outer transaction to commit even if the inner transaction rolls back. In this example, the product is saved into the database, and the order record is rolled back.

4. Independent Transactions Without Exception Handling

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?

@Service
public class ProductService {
    
    @Autowired
    private OrderService orderService;
    
    @Transactional
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        orderService.createOrder();
    }
}

@Service
public class OrderService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createOrder() {
        Order order = new Order();
        order.setTitle("Independent Order");
        
        orderRepository.save(order);
        
        throw new RuntimeException("Order processing failed");
    }
}

We see that both transactions have been rolled back. Because the inner transaction threw an exception, the outer transaction detected the exception and it hasn’t been handled. Therefore, the outer transaction has been rolled back.

5. Outer Transaction Failure with Independent Inner Transaction

When the inner transaction uses REQUIRES_NEW and completes successfully, its changes remain committed even if the outer transaction fails:

@Service
public class ProductService {
    
    @Autowired
    private OrderService orderService;
    
    @Transactional
    public void createProduct() {
        Product product = new Product();
        product.setTitle("Test Product");
        product.setPrice(100);
        
        productRepository.save(product);
        
        orderService.createOrder();
        
        throw new RuntimeException("Product processing failed");
    }
}

@Service
public class OrderService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createOrder() {
        Order order = new Order();
        order.setTitle("Independent Order");
        
        orderRepository.save(order);
    }
}

In this example, the order is saved, product is NOT saved. The inner transaction with REQUIRES_NEW completes and commits independently first. When the outer transaction fails, only the outer transaction rolls back while the inner transaction’s changes remain permanent.

Common Pitfalls and Solutions

Understanding common pitfalls helps avoid transaction-related bugs in production applications.

Self-Invocation Problem

Internal method calls bypass Spring’s transaction proxy, causing @transactional annotations to be ignored:

@Service
public class ProductService {
    
    @Autowired
    private ApplicationContext applicationContext;
    
    @Transactional
    public void createMultipleProducts() {
        ProductService self = applicationContext.getBean(ProductService.class);
        self.createProduct1();
        self.createProduct2();
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createProduct1() {
        productRepository.save(new Product("Product 1"));
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createProduct2() {
        productRepository.save(new Product("Product 2"));
        throw new RuntimeException("Should rollback");
    }
}

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.

Private Method Limitations

The @Transactional annotations only work on public methods due to proxy limitations.

@Service
public class ProductService {
    
    @Transactional
    public void createProductPublic() {
        Product product = new Product("Public Method");
        productRepository.save(product);
        
        throw new RuntimeException("Will rollback correctly");
    }
    
    @Transactional
    private void createProductPrivate() {
        Product product = new Product("Private Method");
        productRepository.save(product);
        
        throw new RuntimeException("Annotation ignored - won't rollback!");
    }
}

Async Method Issues

Asynchronous methods run in different threads, causing transaction context to be lost:

@Service
public class AsyncProductService {
    
    @Autowired
    private TransactionTemplate transactionTemplate;
    
    @Async
    public CompletableFuture<Product> createProductAsync() {
        return CompletableFuture.supplyAsync(() -> {
            return transactionTemplate.execute(status -> {
                try {
                    Product product = new Product("Async Product");
                    productRepository.save(product);
                    
                    if (product.getPrice() < 0) {
                        throw new RuntimeException("Will rollback correctly");
                    }
                    
                    return product;
                } catch (Exception e) {
                    status.setRollbackOnly();
                    throw e;
                }
            });
        });
    }
}

Conclusion

In this tutorial, we explored the fundamental concepts of transactions in Spring and how to rollback spring transaction on checked exception. We began by understanding the basic usage of the @Transactional annotation, which automatically starts and commits transactions, rolling them back when exceptions occur. We dive into the differentiation between checked and unchecked exceptions, exploring how to control rollback behavior using attributes like rollbackFor and noRollbackFor.

In the later part, we examined the interaction between transactions and try-catch blocks. Furthermore, we explored the use of Propagation.REQUIRES_NEW to create new transactions and how the behavior of inner and outer transactions can be controlled independently.

Leave a Comment