Skip to content

Fix: Spring Boot @Transactional Not Rolling Back — Transaction Committed Despite Exception

FixDevs ·

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) and Error. A checked exception (IOException, SQLException, custom Exception subclasses) is committed unless you specify rollbackFor.
  • Self-invocation bypasses the proxy — calling a @Transactional method from within the same class (this.method()) skips the proxy entirely. The transaction annotation is never seen.
  • @Transactional on private methods — Spring AOP proxies can’t intercept private methods. The annotation is silently ignored.
  • Wrong propagationREQUIRES_NEW starts 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-proxy

Fix 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:

PropagationBehavior
REQUIRED (default)Join existing or create new
REQUIRES_NEWAlways create new, suspend existing
SUPPORTSJoin if exists, run without if not
NOT_SUPPORTEDRun without transaction, suspend existing
MANDATORYMust have existing transaction, else throw
NEVERMust NOT have transaction, else throw
NESTEDNested 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 EntityManager

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles