You add @Transactional to a method, expecting that if anything fails, the whole thing rolls back. Then one day a payment fails halfway through, and instead of rolling back, half the work is committed to your database. The annotation is right there. So why didn't it work?
If you've ever stared at a method that clearly has @Transactional on it and still behaved like it didn't, this is for you. Almost every one of these surprises comes down to a single thing most developers never learned: how Spring actually applies the annotation. Once you understand that, every gotcha below stops being mysterious.
Every example here is runnable. The full project — each gotcha shown broken-vs-fixed with passing tests — is on GitHub: transactional-gotchas. Clone it, run
mvn test, and watch the behavior for yourself.
The one thing to understand: it's a proxy
@Transactional is not magic baked into your method. When Spring starts up, it wraps your bean in a proxy — a generated object that sits in front of your real class. The transaction logic (open a transaction, commit, roll back) lives in that proxy, not in your code.
So the flow looks like this:
caller -> [proxy: starts transaction] -> your real method -> [proxy: commits or rolls back]
The proxy only does its job when the call passes through it. The moment a call skips the proxy, @Transactional does nothing at all — silently. Hold onto that sentence. It explains everything that follows.
Gotcha #1: Self-invocation
Here's a service that looks completely correct:
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void placeOrder(Order order) {
validate(order);
saveOrder(order); // internal call
}
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
// more DB work...
}
}
saveOrder has @Transactional. But when placeOrder calls saveOrder(order), it's really calling this.saveOrder(order) — a direct call on the real object. That call never leaves the bean, so it never passes through the proxy. No transaction is started. If saveOrder fails partway, nothing rolls back.
The annotation is ignored, and there's no error or warning to tell you.
The fix: make the call go through the proxy. The cleanest way is to move the transactional method into a separate bean:
@Service
public class OrderService {
private final OrderPersistenceService persistence;
public OrderService(OrderPersistenceService persistence) {
this.persistence = persistence;
}
public void placeOrder(Order order) {
validate(order);
persistence.saveOrder(order); // now goes through the proxy
}
}
@Service
public class OrderPersistenceService {
private final OrderRepository orderRepository;
public OrderPersistenceService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}
Because OrderService calls persistence.saveOrder(...) on an injected bean, the call hits that bean's proxy, and the transaction works.
How do you prove the broken version really has no transaction? You ask Spring directly. TransactionSynchronizationManager.isActualTransactionActive() returns true only when there's a live transaction on the current thread. In the demo repo, the self-invocation test shows the broken path reporting no active transaction and the fixed path reporting an active one — same logic, one structural change.
See it in the repo: BrokenOrderService vs FixedOrderService + OrderPersistenceService.
Gotcha #2: Rollback only happens on unchecked exceptions
This one bites people who use checked exceptions for business logic:
@Transactional
public void processPayment(Payment payment) throws PaymentException {
paymentRepository.save(payment);
if (gatewayDeclined(payment)) {
throw new PaymentException("Payment declined"); // checked exception
}
}
You'd expect the save to roll back when the payment is declined. It doesn't. By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) — but it commits when a checked exception is thrown. So the half-saved payment stays in your database.
The fix: tell Spring what to roll back on:
@Transactional(rollbackFor = PaymentException.class)
public void processPayment(Payment payment) throws PaymentException {
paymentRepository.save(payment);
if (gatewayDeclined(payment)) {
throw new PaymentException("Payment declined");
}
}
Now the checked exception triggers a rollback. If you want everything to roll back on any exception, use rollbackFor = Exception.class.
The cleanest way to feel this is the contrast: in the rollback test, a declined payment leaves paymentRepository.count() == 1 without rollbackFor (the failed payment committed) and == 0 with it. One annotation parameter flips the row from "stuck in your database" to "cleanly gone." Compare BrokenPaymentService and FixedPaymentService.
Gotcha #3: Propagation, and the audit-log trap
Sometimes you want part of your work to survive even when the main transaction fails. The classic example: an audit log. If an order fails, you still want a record that someone tried to place it.
Propagation controls how a transactional method relates to an already-running transaction. The default is REQUIRED — join the existing transaction if there is one. That means your audit write rolls back together with the order. Not what you want here.
REQUIRES_NEW suspends the current transaction and runs in its own independent one, which commits separately:
@Service
public class AuditService {
private final AuditRepository auditRepository;
public AuditService(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event) {
auditRepository.save(new AuditEntry(event));
}
}
Now the audit entry commits in its own transaction, so it persists even if the surrounding order transaction rolls back. (Note: the same proxy rule from Gotcha #1 applies — call auditService.record(...) on an injected bean, not from inside the same class, or the new transaction never starts.)
The propagation test makes it concrete: an OrderProcessingService saves an order, writes an audit entry through AuditService, then throws. After the dust settles, the order is gone (orderRepository.count() == 0) but the audit entry survives (auditRepository.count() == 1).
A quick rule of thumb: reach for REQUIRES_NEW only when the inner work is genuinely independent of the outer work. Overusing it leads to confusing half-committed states.
Prove it with a test
Claims are cheap. A test that shows the rollback actually happening is what makes a tutorial trustworthy — and what catches your own mistakes:
@SpringBootTest
class PaymentServiceTest {
@Autowired PaymentService paymentService;
@Autowired PaymentRepository paymentRepository;
@Test
void declinedPaymentRollsBack() {
Payment payment = new Payment("declined-card", 100);
assertThrows(PaymentException.class,
() -> paymentService.processPayment(payment));
// With rollbackFor in place, nothing should be persisted
assertEquals(0, paymentRepository.count());
}
}
Run this once without rollbackFor and watch the count come back as 1 — the failed payment committed. Add rollbackFor, run it again, and the count is 0. That contrast is the whole article in one test. The repo runs all three gotchas this way; mvn test goes green with five passing tests.
The one thing to remember
If you forget everything else, remember this: @Transactional works through a proxy, and only when the call passes through it. Self-invocation skips the proxy. Checked exceptions don't roll back unless you say so. Propagation decides whether you join or start fresh. Every gotcha is a variation on the same idea.
Add the annotation with that mental model in mind, and it stops surprising you.
The full runnable project is here if you want to break it and fix it yourself: github.com/abhishekpratapsingh2601-arch/transactional-gotchas.
Got a @Transactional war story of your own? I'd like to hear which one cost you the most time to track down.