Why Your @Transactional Method Is Being Ignored
You annotate a method with @Transactional, an exception is thrown halfway through, and nothing rolls back. No error, no warning. The annotation is simply ignored.
This is almost always the same bug, and it comes down to how Spring implements the annotation.
@Transactional is a proxy, not a keyword
@Transactional is not compiled into your method. When Spring finds the annotation, it wraps your bean in a proxy, a generated subclass that opens a transaction, calls your method, then commits or rolls back.
Everything that calls your bean through Spring gets the proxy:
caller ──▶ OrderService$$SpringCGLIB ──▶ OrderService
(begin / commit / rollback) (your code)
Which gives you the rule that explains every case below:
The transaction starts when a call crosses the proxy boundary. A call from one method of a bean to another method of the same bean never does.
The classic failure: self-invocation
@Service
public class OrderService {
public void processAll(List<Order> orders) {
for (Order order : orders) {
processOne(order); // plain `this.processOne(...)`
}
}
@Transactional
public void processOne(Order order) {
// NOT transactional when called from processAll
}
}
processAll calls this.processOne(...). this is your object, not the proxy, so the proxy never sees the call and the annotation does nothing. Call processOne from a controller and it works perfectly, which is what makes this so confusing to debug.
Fix 1: Move the method to another bean (preferred)
If the call crosses into a different bean, it goes through that bean's proxy:
@Service
public class OrderService {
private final OrderProcessor processor;
public OrderService(OrderProcessor processor) {
this.processor = processor;
}
public void processAll(List<Order> orders) {
orders.forEach(processor::processOne); // crosses the boundary
}
}
@Service
public class OrderProcessor {
@Transactional
public void processOne(Order order) {
// genuinely transactional
}
}
This is usually the right answer. The awkwardness of the original code was a hint that two responsibilities were living in one class.
Fix 2: Inject the proxy into itself
Occasionally splitting the class is overkill. You can ask Spring for your own proxy:
@Service
public class OrderService {
@Lazy
private final OrderService self;
public OrderService(@Lazy OrderService self) {
this.self = self;
}
public void processAll(List<Order> orders) {
orders.forEach(self::processOne); // through the proxy
}
@Transactional
public void processOne(Order order) { }
}
@Lazy is required, otherwise the constructor needs the bean it is currently constructing. This works, but it is a workaround. Reach for it when Fix 1 genuinely does not fit.
Fix 3: TransactionTemplate
If you want the transaction boundary to be obvious rather than implied, skip the annotation:
@Service
public class OrderService {
private final TransactionTemplate tx;
public void processOne(Order order) {
tx.executeWithoutResult(status -> {
// definitely inside a transaction
});
}
}
No proxies, no rules to remember. The boundary is right there in the code.
The other three ways to lose your transaction
Non-public methods. Spring's proxies only intercept public methods. On a private, protected or package-private method the annotation is silently ignored.
@Transactional
private void save(Order order) { } // never transactional
Checked exceptions. By default Spring rolls back on RuntimeException and Error, but commits on checked exceptions. This surprises people constantly:
@Transactional // commits if IOException is thrown
public void importFile() throws IOException { }
@Transactional(rollbackFor = Exception.class) // rolls back on anything
public void importFile() throws IOException { }
Catching the exception yourself. If you swallow it, Spring never sees it and has no reason to roll back:
@Transactional
public void process(Order order) {
try {
repo.save(order);
} catch (Exception e) {
log.error("failed", e); // transaction commits
}
}
If you must catch it, mark the transaction yourself:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
Checking your work
Add this to application.properties and you can see exactly where transactions begin and end:
logging.level.org.springframework.transaction.interceptor=TRACE
If your method does not appear in the log, it is not transactional, and the reason is almost certainly one of the five above.