Fix: Spring Boot @Transactional Not Rolling Back — Transaction Committed Despite Exception
Quick Answer
How to fix Spring @Transactional not rolling back — checked vs unchecked exceptions, self-invocation proxy bypass, rollbackFor, transaction propagation, and nested transactions.
The Problem
A Spring @Transactional method doesn’t roll back when an exception is thrown:
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
inventoryService.deductStock(order); // Throws a checked exception
// Order is saved but stock not deducted — partial state in DB
}
}Or a rollback is expected but the data remains committed:
@Transactional
public void transfer(String from, String to, BigDecimal amount) {
accountRepository.debit(from, amount);
accountRepository.credit(to, amount); // Throws RuntimeException
// Both operations committed — debit happened, credit failed
// Transaction should have rolled back the debit too
}Or @Transactional on a private method is silently ignored:
@Transactional
private void saveData(Data data) { // Annotation has no effect
repository.save(data);
}Why This Happens
Spring’s @Transactional works through a proxy. Several scenarios break it:
- Checked exceptions don’t trigger rollback by default — Spring only rolls back for
RuntimeException(unchecked) andError. A checked exception (IOException,SQLException, customExceptionsubclasses) is committed unless you specifyrollbackFor. - Self-invocation bypasses the proxy — calling a
@Transactionalmethod from within the same class (this.method()) skips the proxy entirely. The transaction annotation is never seen. @Transactionalon private methods — Spring AOP proxies can’t intercept private methods. The annotation is silently ignored.- Wrong propagation —
REQUIRES_NEWstarts a new transaction; exceptions in the outer method don’t roll back the inner transaction and vice versa. - Exception caught and swallowed — if a try-catch inside the method catches and doesn’t rethrow the exception, Spring doesn’t know the method failed.
Fix 1: Add rollbackFor for Checked Exceptions
By default, Spring only rolls back on RuntimeException and Error:
// WRONG — IOException is a checked exception, won't trigger rollback
@Transactional
public void processFile(String path) throws IOException {
Record record = fileService.parse(path); // May throw IOException
repository.save(record);
// If IOException is thrown, record IS committed (no rollback)
}
// CORRECT — specify rollbackFor
@Transactional(rollbackFor = Exception.class)
public void processFile(String path) throws Exception {
Record record = fileService.parse(path);
repository.save(record);
// Now IOException causes a rollback
}
// Or specify exact exception types
@Transactional(rollbackFor = { IOException.class, ValidationException.class })
public void processFile(String path) throws IOException {
// ...
}
// Alternatively — wrap in a RuntimeException to trigger default rollback
@Transactional
public void processFile(String path) {
try {
Record record = fileService.parse(path);
repository.save(record);
} catch (IOException e) {
throw new RuntimeException("File processing failed", e); // Unchecked — rolls back
}
}Default rollback rules:
- Rolls back:
RuntimeException,Error(and subclasses) - Does NOT roll back:
Exception,IOException,SQLException, and any checked exceptions
Fix 2: Fix Self-Invocation (Proxy Bypass)
The most common cause of @Transactional being silently ignored — calling an annotated method from within the same class:
@Service
public class UserService {
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
this.createProfile(user); // WRONG — bypasses proxy, no transaction on createProfile
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
// This runs in the OUTER transaction, not a new one
// The REQUIRES_NEW annotation is completely ignored
}
}Fix — inject the bean into itself (Spring provides a self-reference):
@Service
public class UserService {
@Autowired
private UserService self; // Spring injects the proxy, not 'this'
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
self.createProfile(user); // Goes through proxy — @Transactional honored
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
// Now runs in its own transaction
}
}Fix — extract the inner method to a separate service:
// Cleaner solution — separate services
@Service
public class UserService {
@Autowired
private ProfileService profileService;
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
profileService.createProfile(user); // Different bean — proxy works
}
}
@Service
public class ProfileService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
}
}Fix — use ApplicationContext to get the proxy:
@Service
public class UserService implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.applicationContext = ctx;
}
@Transactional
public void createUserWithProfile(UserDto dto) {
User user = userRepository.save(new User(dto));
// Get the proxied bean from context
UserService proxy = applicationContext.getBean(UserService.class);
proxy.createProfile(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createProfile(User user) {
profileRepository.save(new Profile(user));
}
}Fix 3: @Transactional on Public Methods Only
Spring AOP only intercepts public methods. Move @Transactional to public methods or use AspectJ weaving for private methods:
// WRONG — @Transactional on private method is ignored
@Transactional
private void saveInternal(Data data) {
repository.save(data);
}
// CORRECT — public method
@Transactional
public void save(Data data) {
repository.save(data);
}
// If you need private transactional logic — call through a public method
public void processData(Data data) {
validateData(data); // Private — OK (no transaction annotation)
saveData(data); // Public @Transactional method
}
@Transactional
public void saveData(Data data) {
repository.save(data);
}AspectJ weaving (compile-time) — allows @Transactional on private methods:
<!-- pom.xml — enable AspectJ load-time weaving -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>// application.properties
spring.aop.proxy-target-class=true // Use CGLIB proxy (default in Spring Boot)
// spring.aop.auto=true // Enable AOP auto-proxyFix 4: Understand Transaction Propagation
Propagation controls how transactions behave when methods call each other:
@Service
public class OrderService {
@Transactional // Default: REQUIRED — joins existing transaction or creates new one
public void placeOrder(Order order) {
orderRepository.save(order);
notificationService.sendConfirmation(order); // Joins THIS transaction
}
}
@Service
public class NotificationService {
// REQUIRED (default) — joins the caller's transaction
@Transactional
public void sendConfirmation(Order order) {
notificationRepository.save(new Notification(order));
// If this throws, the entire outer transaction rolls back
// Including the orderRepository.save() above
}
// REQUIRES_NEW — always starts a new, independent transaction
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendConfirmationIndependently(Order order) {
notificationRepository.save(new Notification(order));
// If this throws, only THIS transaction rolls back
// The outer orderRepository.save() is NOT rolled back
}
// NOT_SUPPORTED — suspends any existing transaction, runs without one
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void logAction(String message) {
auditLog.append(message);
// Runs outside any transaction — won't be rolled back
}
}Propagation types:
| Propagation | Behavior |
|---|---|
REQUIRED (default) | Join existing or create new |
REQUIRES_NEW | Always create new, suspend existing |
SUPPORTS | Join if exists, run without if not |
NOT_SUPPORTED | Run without transaction, suspend existing |
MANDATORY | Must have existing transaction, else throw |
NEVER | Must NOT have transaction, else throw |
NESTED | Nested within existing (savepoint) |
Fix 5: Don’t Swallow Exceptions
A try-catch that doesn’t rethrow prevents Spring from knowing the method failed:
// WRONG — exception swallowed, no rollback triggered
@Transactional
public void processOrder(Order order) {
try {
orderRepository.save(order);
paymentService.charge(order); // Throws PaymentException
} catch (PaymentException e) {
log.error("Payment failed", e);
// Exception swallowed — Spring commits the transaction!
// orderRepository.save() is committed even though payment failed
}
}
// CORRECT — rethrow or convert to unchecked exception
@Transactional(rollbackFor = PaymentException.class)
public void processOrder(Order order) throws PaymentException {
try {
orderRepository.save(order);
paymentService.charge(order);
} catch (PaymentException e) {
log.error("Payment failed", e);
throw e; // Rethrow — Spring sees the exception and rolls back
}
}
// Or mark for rollback manually without rethrowing
@Transactional
public void processOrder(Order order) {
orderRepository.save(order);
try {
paymentService.charge(order);
} catch (PaymentException e) {
log.error("Payment failed: {}", e.getMessage());
// Manually mark for rollback without throwing
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}Fix 6: Verify Transaction is Active
Debug whether a transaction is actually active when you expect one:
@Service
public class DiagnosticsService {
@Transactional
public void checkTransaction() {
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
String txName = TransactionSynchronizationManager.getCurrentTransactionName();
System.out.println("Transaction active: " + active);
System.out.println("Transaction name: " + txName);
// If active = false inside @Transactional, the proxy isn't being used
// (self-invocation or wrong bean scope)
}
}Enable transaction logging:
# application.properties
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
# Output shows:
# Creating new transaction with name [com.example.UserService.createUser]
# Participating in existing transaction
# Rolling back JPA transaction on EntityManager
# Committing JPA transaction on EntityManagerFix 7: @Transactional on Spring Tests
In tests, @Transactional auto-rolls back after each test — useful for isolation:
@SpringBootTest
@Transactional // Each test method rolls back automatically
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void createUser_savesToDatabase() {
userService.createUser(new UserDto("[email protected]"));
Optional<User> user = userRepository.findByEmail("[email protected]");
assertThat(user).isPresent();
// After this test, the transaction rolls back — DB is clean for next test
}
@Test
@Commit // Override — commit this test's data (use carefully)
void createUser_persistsAfterCommit() {
userService.createUser(new UserDto("[email protected]"));
// This data STAYS in the database — other tests may see it
}
}Still Not Working?
@Transactional on interface vs implementation — annotating the interface method works with JDK dynamic proxies. If Spring uses CGLIB (the default in Spring Boot), annotate the implementation class, not the interface.
@EnableTransactionManagement missing — in pure Spring (non-Boot) apps, add @EnableTransactionManagement to a @Configuration class. Spring Boot auto-configures this.
Multiple DataSource beans — if you have more than one data source, you may need to specify which TransactionManager to use: @Transactional("myTransactionManager").
JPA lazy loading after transaction ends — accessing a lazy-loaded collection after the @Transactional method returns throws LazyInitializationException. Either fetch eagerly (JOIN FETCH), use @Transactional(readOnly = true) on the calling service, or use an OpenEntityManagerInViewFilter.
For related Spring issues, see Fix: Spring Boot DataSource Failed to Configure and Fix: Spring Boot Port Already in Use.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Spring Data JPA Query Not Working — @Query, Derived Methods, and N+1 Problems
How to fix Spring Data JPA query issues — JPQL vs native SQL, derived method naming, @Modifying for updates, pagination, projections, and LazyInitializationException.
Fix: Spring Boot Failed to Configure a DataSource
How to fix 'Failed to configure a DataSource: url attribute is not specified' in Spring Boot — adding database properties, excluding DataSource auto-configuration, H2 vs production DB setup, and multi-datasource configuration.
Fix: Spring Boot @Cacheable Not Working — Cache Miss Every Time or Stale Data
How to fix Spring Boot @Cacheable issues — @EnableCaching missing, self-invocation bypass, key generation, TTL configuration, cache eviction, and Caffeine vs Redis setup.
Fix: Hibernate LazyInitializationException — Could Not Initialize Proxy
How to fix Hibernate LazyInitializationException — loading lazy associations outside an active session, fetch join, @Transactional scope, DTO projection, and Open Session in View.