@Transactional looks deceptively simple — slap it on a method, and Spring handles the rest. But then your database has inconsistent data, your rollback didn’t happen, or the annotation seems to do absolutely nothing. Something is silently wrong.

TLDR — Top 3 Fixes

Fix 1 — Self-invocation (most common): Don’t call @Transactional methods from within the same class. Extract them to a separate bean.

Fix 2 — Wrong exception type: Add rollbackFor = Exception.class if you’re throwing checked exceptions.

Fix 3 — Private method: @Transactional only works on public methods. Make the method public.

How to Diagnose the Problem

Before diving into fixes, narrow down which failure mode you’re dealing with. Ask yourself:

  • Did the transaction not start at all? (Check with TransactionSynchronizationManager.isActualTransactionActive())
  • Did the transaction start but not roll back on error?
  • Is the wrong data being written (isolation or propagation issue)?

Enable Spring’s transaction logging to get visibility:

# application.properties
logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm.jpa=DEBUG

With TRACE enabled, you’ll see log lines like Getting transaction for [...] and Completing transaction for [...]. If you don’t see those lines for your method, the transaction isn’t being applied at all — which points to causes #1 or #3 below. If you see the transaction start but no rollback log, that’s causes #2 or #5.


Cause #1: Self-Invocation Bypasses the Proxy

This is the most common @Transactional gotcha and the one that trips up the most experienced developers.

Spring’s transaction support is implemented through AOP proxies. When another bean calls your @Transactional method, it goes through the proxy, which opens a transaction. But when you call a @Transactional method from within the same class, you’re calling this.someMethod() — which skips the proxy entirely.

// This code triggers the problem:
@Service
public class OrderService {

    public void placeOrder(Order order) {
        // ⚠️ Calls this.saveOrderAndNotify() directly on 'this', bypassing the proxy
        saveOrderAndNotify(order);
    }

    @Transactional
    public void saveOrderAndNotify(Order order) {
        orderRepository.save(order);
        notificationRepository.save(new Notification(order));
    }
}

When placeOrder() calls saveOrderAndNotify(), Spring’s transactional proxy is never involved. If notificationRepository.save() throws, the orderRepository.save() won’t roll back — there’s no transaction wrapping the call.

The fix: extract to a separate bean.

@Service
public class OrderService {

    private final OrderTransactionService orderTransactionService;

    public OrderService(OrderTransactionService orderTransactionService) {
        this.orderTransactionService = orderTransactionService;
    }

    public void placeOrder(Order order) {
        // ✅ Now goes through Spring's proxy
        orderTransactionService.saveOrderAndNotify(order);
    }
}

@Service
public class OrderTransactionService {

    @Transactional
    public void saveOrderAndNotify(Order order) {
        orderRepository.save(order);
        notificationRepository.save(new Notification(order));
    }
}

An alternative (but less clean) approach is to inject the bean itself via ApplicationContext and call the method on the injected proxy:

@Service
public class OrderService implements ApplicationContextAware {

    private ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.context = ctx;
    }

    public void placeOrder(Order order) {
        // Gets the proxied version of this bean
        OrderService self = context.getBean(OrderService.class);
        self.saveOrderAndNotify(order);
    }

    @Transactional
    public void saveOrderAndNotify(Order order) {
        // ...
    }
}

That said, the separate-bean approach is cleaner and easier to test. The self-injection trick is useful when refactoring is too expensive in the short term.


Cause #2: Rollback Only Applies to Unchecked Exceptions

By default, @Transactional only triggers a rollback for unchecked exceptions — that is, RuntimeException and its subclasses (including Error). If your method throws a checked exception (one that extends Exception but not RuntimeException), Spring will commit the transaction, not roll it back.

// ❌ Transaction commits even though an exception is thrown
@Transactional
public void transferFunds(Long fromId, Long toId, BigDecimal amount) throws InsufficientFundsException {
    Account from = accountRepository.findById(fromId).orElseThrow();
    if (from.getBalance().compareTo(amount) < 0) {
        throw new InsufficientFundsException("Insufficient balance"); // Checked exception!
    }
    from.debit(amount);
    accountRepository.findById(toId).orElseThrow().credit(amount);
}

InsufficientFundsException extends Exception, so Spring sees it as a checked exception and commits any partial changes.

Fix: specify rollback exceptions explicitly.

// ✅ Rolls back for both checked and unchecked exceptions
@Transactional(rollbackFor = Exception.class)
public void transferFunds(Long fromId, Long toId, BigDecimal amount) throws InsufficientFundsException {
    Account from = accountRepository.findById(fromId).orElseThrow();
    if (from.getBalance().compareTo(amount) < 0) {
        throw new InsufficientFundsException("Insufficient balance");
    }
    from.debit(amount);
    accountRepository.findById(toId).orElseThrow().credit(amount);
}

You can also use rollbackFor = InsufficientFundsException.class if you want to be more targeted. Alternatively, convert your custom exceptions to extend RuntimeException:

// Cleaner in many cases — makes it an unchecked exception
public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

Which approach is better depends on your project conventions. If you use checked exceptions for business-logic errors (the traditional Java style), use rollbackFor. If you prefer unchecked exceptions everywhere (common in Spring projects), extend RuntimeException.


Cause #3: @Transactional on a Non-Public Method

Spring’s proxy-based AOP can only intercept public method calls. If you put @Transactional on a private, protected, or package-private method, it’s silently ignored.

@Service
public class PaymentService {

    // ❌ @Transactional is silently ignored on private methods
    @Transactional
    private void processPayment(Payment payment) {
        paymentRepository.save(payment);
        ledgerRepository.save(payment.toLedgerEntry());
    }
}

No error is thrown — the annotation just does nothing. This makes it particularly insidious to debug. Your code compiles, runs, and writes to the database, but there’s no transaction wrapping the work.

Fix: make the method public.

@Service
public class PaymentService {

    // ✅ Public method — proxy can intercept it
    @Transactional
    public void processPayment(Payment payment) {
        paymentRepository.save(payment);
        ledgerRepository.save(payment.toLedgerEntry());
    }
}

If you don’t want the method to be part of the class’s public API, move it to a separate internal service or use a package-private class. It’s better than having a silent no-op annotation.

Note: If you use AspectJ weaving instead of Spring’s default proxy-based AOP, @Transactional does work on private methods. But most Spring Boot apps use the default proxy approach, so this note is mainly relevant for special setups.


Cause #4: Exception Is Caught Before It Propagates

This one is easy to overlook. If you catch an exception inside your @Transactional method and don’t re-throw it, Spring never sees the exception — and therefore never rolls back.

// ❌ Exception swallowed, transaction commits with partial data
@Transactional
public void createUserAndProfile(UserDto dto) {
    User user = userRepository.save(new User(dto.getEmail()));
    try {
        profileRepository.save(new Profile(user.getId(), dto.getBio()));
    } catch (DataIntegrityViolationException e) {
        log.error("Failed to create profile", e); // Exception swallowed here
    }
}

Spring’s rollback mechanism fires when the exception bubbles up past the method boundary. If you catch it inside, the transaction ends normally (commits), even though the second save failed.

Fix: re-throw after logging, or mark the transaction for rollback manually.

// ✅ Option 1: Re-throw the exception
@Transactional
public void createUserAndProfile(UserDto dto) {
    User user = userRepository.save(new User(dto.getEmail()));
    try {
        profileRepository.save(new Profile(user.getId(), dto.getBio()));
    } catch (DataIntegrityViolationException e) {
        log.error("Failed to create profile", e);
        throw e; // Let Spring see the exception and roll back
    }
}

// ✅ Option 2: Programmatically mark the transaction for rollback
@Transactional
public void createUserAndProfile(UserDto dto) {
    User user = userRepository.save(new User(dto.getEmail()));
    try {
        profileRepository.save(new Profile(user.getId(), dto.getBio()));
    } catch (DataIntegrityViolationException e) {
        log.error("Failed to create profile", e);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

Option 2 is useful when you need to handle the exception gracefully (e.g., return a user-friendly error response) but still want to roll back.


Cause #5: Wrong Propagation Setting

@Transactional defaults to Propagation.REQUIRED, which joins an existing transaction if one is already active. If you call a @Transactional method from within an existing transaction, they share the same transaction. That’s usually what you want — but not always.

A subtle bug can occur with Propagation.REQUIRES_NEW. When you set this, Spring suspends the outer transaction and starts a fresh one. Changes in the inner transaction are committed independently, even if the outer transaction rolls back.

@Service
public class AuditService {

    // This commits independently, even if the caller rolls back
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAuditEvent(String event) {
        auditRepository.save(new AuditLog(event));
    }
}

This is actually the correct pattern for audit logging — you want the audit record to persist even if the main transaction fails. But if you accidentally use REQUIRES_NEW where you expect shared transaction behavior, changes might commit when you expect them to roll back.

Check your propagation settings if the rollback behavior is inconsistent across different call paths.


Cause #6: @Transactional on an Interface vs. Implementation

There’s a subtle difference between annotating an interface method and annotating the implementation. With Spring’s default JDK proxy approach (for interface-based beans), annotating the interface works fine. But if Spring uses CGLIB proxies (for class-based beans without an interface, or when proxyTargetClass = true), annotating only the interface may not work.

The safest practice is to annotate the implementation class, not the interface:

// Interface — avoid putting @Transactional here if using CGLIB
public interface UserService {
    void createUser(UserDto dto);
}

// ✅ Put @Transactional on the implementation
@Service
public class UserServiceImpl implements UserService {

    @Transactional
    @Override
    public void createUser(UserDto dto) {
        // ...
    }
}

This works regardless of whether Spring uses JDK or CGLIB proxies.


Still Not Working?

If you’ve checked all the above and @Transactional still isn’t behaving, here are some less common edge cases:

Transaction management not enabled (rare in Spring Boot): In a plain Spring application (not Spring Boot), you need @EnableTransactionManagement on your configuration class. Spring Boot adds this automatically via auto-configuration, so this usually isn’t the issue — but worth checking if you’re migrating from a non-Boot setup.

Multiple transaction managers: If your application has more than one PlatformTransactionManager bean (e.g., for multiple data sources), @Transactional defaults to the primary one. If you’re operating on a different data source, specify the transaction manager explicitly:

@Transactional("secondaryTransactionManager")
public void saveToSecondaryDb(Entity entity) {
    secondaryRepository.save(entity);
}

Tests using @Transactional: In tests, @Transactional rolls back after each test by default. This is great for isolation but can mask production behavior. If your test always passes but production doesn’t, double-check that the rollback isn’t just hiding the problem.

Entity manager and session scope: If you’re using the EntityManager directly, make sure operations happen within the same persistence context. Detached entities can cause changes to be silently ignored even inside a transaction.


Summary Checklist

Run through these before spending more time debugging:

  • [ ] Is the @Transactional method being called through Spring’s proxy (not via this)?
  • [ ] Is the method public?
  • [ ] Is the class a Spring-managed bean (@Service, @Component, etc.)?
  • [ ] Does the exception extend RuntimeException, or did you add rollbackFor?
  • [ ] Is the exception being caught and swallowed before it reaches Spring?
  • [ ] Is Propagation.REQUIRES_NEW used anywhere in the call chain unexpectedly?
  • [ ] If you have multiple data sources, is the right transaction manager specified?
  • [ ] Did you enable logging.level.org.springframework.transaction=TRACE to confirm the transaction is being opened?

Spring’s @Transactional is powerful but its proxy-based design means it’s only as reliable as the call path through which you invoke it. The self-invocation and private-method issues in particular are classic Spring gotchas that almost every Spring developer hits at some point.

Use Debugly’s trace formatter to quickly parse and analyze Java stack traces when your transactions blow up at runtime — it makes it much easier to see exactly where in the call stack an exception originated and whether it crossed through a Spring proxy method.

For related reading, check out how Spring’s BeanCreationException can surface at startup when transaction infrastructure beans aren’t configured correctly, and the Spring @Autowired null fix guide if your transaction-annotated beans aren’t being injected as expected.