Here is a bug that turns up in a lot of Spring codebases. A method is annotated @Transactional. It saves an order, then charges a card. The charge fails. And the order is still in the database.
Nobody removed the annotation. No test failed. Code review approved it. The compiler had no opinion. The transaction just never happened, or it happened and committed anyway, and nothing told anyone.
I want to walk through the seven ways this happens, because once you know how @Transactional actually works, every one of them is obvious. Until then, they are invisible.
How @Transactional actually works
Spring does not change your method. When it creates a bean with @Transactional methods, it wraps that bean in a proxy. Other beans get the proxy injected, not your object. When they call a transactional method, the call goes through the proxy, which begins a transaction, calls your method, and then commits or rolls back based on what came out.
Keep two facts in mind and the rest of this post explains itself:
-
The proxy only acts on calls that go through the proxy.
-
The proxy decides to roll back by looking at the exception that leaves your method, and only that.
1. Calling a @Transactional method from the same class
@Servicepublic class OrderService { public void checkout(Cart cart) { validate(cart); placeOrder(cart); // this.placeOrder(): no proxy, no transaction } @Transactional public void placeOrder(Cart cart) { orderRepository.save(Order.from(cart)); paymentClient.charge(cart.total()); // throws }}The controller calls checkout through the proxy, but checkout isn't transactional. It then calls placeOrder on this, the plain object, so the proxy never sees the call. No transaction starts. orderRepository.save runs in its own short transaction (Spring Data repository methods are transactional themselves) and commits immediately. Then the charge fails, and the order stays.
The same thing happens with @Transactional(propagation = REQUIRES_NEW) on a method in the same class: you never get the new transaction.
Fix: put the transaction on the method the outside world calls, move the transactional method into its own bean, or make the boundary explicit:
public void checkout(Cart cart) { validate(cart); transactionTemplate.executeWithoutResult(status -> { orderRepository.save(Order.from(cart)); paymentClient.charge(cart.total()); });}2. Private and final methods
The proxy Spring Boot creates by default is a generated subclass of your bean. It can only intercept methods it can override. A private method can never be overridden, so @Transactional on it does nothing. A final method can't be overridden either, so the same applies.
Since Spring Framework 6.0, protected and package-private methods do work with these class-based proxies. private and final never will. Your IDE may warn you. The compiler won't.
3. Checked exceptions commit
@Transactionalpublic void importStatement(Path file) throws IOException { statementRepository.save(header); for (String line : Files.readAllLines(file)) { // throws IOException halfway lineRepository.save(parse(line)); }}By default, Spring rolls back on RuntimeException and Error only. A checked exception is treated as an expected business outcome, so the transaction commits whatever was written before the exception: the header and half the lines.
Fix: say what you mean on the method, @Transactional(rollbackFor = Exception.class). On Spring Framework 6.2 or later you can change the default once for the whole application:
@Configuration@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)class TransactionConfig { }Spring's own Javadoc recommends this switch unless you deliberately rely on checked exceptions that commit.
4. Catching the exception inside the method
@Transactionalpublic void placeOrder(Cart cart) { orderRepository.save(Order.from(cart)); try { paymentClient.charge(cart.total()); } catch (PaymentException e) { log.error("Payment failed for cart {}", cart.id(), e); }}The proxy decides by looking at what comes out of the method. Nothing comes out, so it commits an unpaid order. The log line makes it look handled.
Fix: rethrow, or mark the rollback explicitly with TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() if you really need to return normally.
The mirror image is even more confusing. Suppose paymentClient.charge is itself a @Transactional method on another bean. It joins your transaction. When it throws, its proxy marks the shared transaction as rollback-only before your catch block even runs. You catch the exception, your method returns normally, the outer proxy tries to commit, and Spring throws UnexpectedRollbackException. Your catch block ran, your log says you handled it, and everything rolled back anyway.
5. Work handed to another thread
Spring keeps the current transaction bound to the current thread. Anything that runs on another thread can't see it: @Async methods, CompletableFuture.runAsync, an executor, a parallel stream.
@Transactionalpublic void placeOrder(Cart cart) { Order order = orderRepository.save(Order.from(cart)); CompletableFuture.runAsync(() -> loyaltyService.addPoints(cart.userId(), order.getId())); paymentClient.charge(cart.total()); // throws: the order rolls back}If the charge fails, the order rolls back. But the loyalty points were added on another thread, in a separate transaction, possibly before the order was even committed. The customer now has points for an order that never existed.
Fix: don't start side effects inside a transaction. Publish an event and handle it after commit, with @TransactionalEventListener (plus @Async if it's slow), or write it to an outbox table in the same transaction and process it separately.
6. noRollbackFor on the wrong method
@Transactional(noRollbackFor = InvalidOtpException.class)public void verify(String userId, String otp) { attemptService.recordAttempt(userId); // must survive a wrong OTP otpChecker.check(userId, otp); // @Transactional, throws InvalidOtpException}The intent is clear: a wrong OTP should still count as an attempt, so brute force gets locked out. But otpChecker.check is also @Transactional, so it joins the same transaction with the default rules. When InvalidOtpException passes through its proxy, that proxy marks the shared transaction rollback-only. The outer method's noRollbackFor is never consulted for that decision.
The result: the attempt counter is rolled back, which is exactly what a brute-force script wants, and the caller gets UnexpectedRollbackException instead of InvalidOtpException.
Fix: record the attempt in its own transaction (REQUIRES_NEW, on a separate bean), drop @Transactional from the checker if it only reads, or put the same rule on every method the exception passes through.
7. Writes from an AFTER_COMMIT event listener
@Componentclass OrderPlacedListener { @TransactionalEventListener // phase = AFTER_COMMIT by default void onOrderPlaced(OrderPlaced event) { notificationService.save(event.userId(), "Your order is confirmed"); }}@Serviceclass NotificationService { @Transactional // REQUIRED public void save(String userId, String message) { notificationRepository.save(new Notification(userId, message)); }}This one runs, logs nothing unusual, and writes nothing. Spring's documentation says it directly: after commit, the transactional resources may still be active, so any data access code triggered at this point still participates in the original transaction, but its changes will not be committed.
Since Spring Framework 6.1, putting @Transactional directly on a @TransactionalEventListener method fails at startup unless it is REQUIRES_NEW or NOT_SUPPORTED. That guard helps, but it can't see the ordinary REQUIRED service you call from the listener, which is where this bug usually lives.
Fix: @Transactional(propagation = Propagation.REQUIRES_NEW) on the method the listener calls, or on the listener itself.
How to see what your transactions are really doing
Most of these traps are invisible because nothing is logged. Turn the logging on in development:
logging: level: org.springframework.transaction.interceptor: TRACE org.springframework.orm.jpa.JpaTransactionManager: DEBUGThe interceptor logs "Getting transaction for [...]" and "Completing transaction for [...]" around every transactional method. The JPA transaction manager logs when it creates a new transaction, participates in an existing one, commits or rolls back. If you expected a transaction and see none of these lines, the proxy was not in the path.
In code, TransactionSynchronizationManager.isActualTransactionActive() tells you whether you are inside a real transaction right now. It's a useful assertion in tests.
A test that catches traps 1 to 4
@SpringBootTestclass PlaceOrderRollbackTest { @Autowired OrderService orderService; @Autowired OrderRepository orderRepository; @MockitoBean PaymentClient paymentClient; @Test void failedPaymentLeavesNoOrder() { doThrow(new PaymentException("declined")).when(paymentClient).charge(any()); assertThatThrownBy(() -> orderService.checkout(sampleCart())) .isInstanceOf(PaymentException.class); assertThat(orderRepository.count()).isZero(); }}One important detail: don't put @Transactional on this test class. A transactional test wraps the whole test in its own transaction and rolls it back at the end, which hides traps 1 to 4 completely. The test passes for the wrong reason.
The checklist
-
Is the transactional method called through another bean, not through
this? -
Is it public or protected, and not final?
-
Do checked exceptions roll back, either through
rollbackForor the 6.2 default switch? -
Does every exception you catch either rethrow or explicitly mark rollback?
-
Is any side effect started on another thread before the transaction commits?
-
Do rollback rules match on every
@Transactionalmethod an exception passes through? -
Do after-commit listeners write in a
REQUIRES_NEWtransaction?
If this looks familiar, it's the same lesson as my flash sale post: @Transactional groups your writes, but it doesn't protect you from everything you might expect it to.
Which of these seven have you hit in production? I'd like to hear the story.

Discussion