Transactions in Spring: @Transactional and Common Pitfalls
1. Overview
@Transactional is the most commonly used annotation in Spring Boot. It’s also a source of many subtle bugs: data not rolling back, transactions “disappearing,” or UnexpectedRollbackException appearing with no clear reason.
This article explains how @Transactional works through a proxy. Then we walk through each common pitfall, with code examples and fixes for each one. By the end, you’ll know when a transaction is truly opened and when it rolls back.
2. Setup
We’ll use Spring Data JPA with an H2 in-memory database:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Enable logging for the transaction interceptor to see when transactions are opened and closed:
logging.level.org.springframework.transaction.interceptor=TRACE
The entity and repository used in this article:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue
private Long id;
private String customerName;
private BigDecimal amount;
protected Order() {
}
public Order(String customerName, BigDecimal amount) {
this.customerName = customerName;
this.amount = amount;
}
public Long getId() { return id; }
public String getCustomerName() { return customerName; }
public BigDecimal getAmount() { return amount; }
}
public interface OrderRepository extends JpaRepository<Order, Long> {
}
The table is named orders because order is an SQL keyword.
3. How @Transactional Works
3.1. Basic Example
@Service
public class PaymentService {
public void charge(String customer, BigDecimal amount) {
if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new IllegalStateException("Exceeded payment limit");
}
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
@Transactional
public Order placeOrder(String customer, BigDecimal amount) {
Order order = orderRepository.save(new Order(customer, amount));
paymentService.charge(customer, amount);
return order;
}
}
When you call placeOrder("An", new BigDecimal("5000")), the order is saved first. Then charge throws an IllegalStateException. Because this is a RuntimeException, Spring rolls back everything, leaving the orders table empty.
3.2. The Proxy is Central
Spring doesn’t modify the bytecode of OrderService. Instead, it creates a proxy (by default a CGLIB subclass in Spring Boot) wrapping the bean. Any other bean that injects OrderService actually receives this proxy. Every method call goes through TransactionInterceptor:
caller → proxy → [open transaction] → OrderService.placeOrder() → [commit/rollback] → caller
The TRACE log makes this clear:
TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: Exceeded payment limit
Most of the pitfalls below stem from one principle: if a call doesn’t go through the proxy, there’s no transaction.
4. Pitfall 1: Self-invocation
@Service
public class OrderService {
// ... constructor as above
public void placeOrders(List<String> customers) {
for (String customer : customers) {
placeOrder(customer, new BigDecimal("100")); // calling through this
}
}
@Transactional
public Order placeOrder(String customer, BigDecimal amount) {
System.out.println("Transaction active: "
+ TransactionSynchronizationManager.isActualTransactionActive());
Order order = orderRepository.save(new Order(customer, amount));
paymentService.charge(customer, amount);
return order;
}
}
Calling orderService.placeOrders(List.of("An", "Bình")) from your controller prints:
Transaction active: false
Transaction active: false
placeOrders has no @Transactional, so the proxy passes the call directly to the real object. Inside, placeOrder(...) is equivalent to this.placeOrder(...), where this is the original object, not the proxy. The annotation is bypassed entirely. Each save runs in its own transaction from SimpleJpaRepository and commits immediately. If the second order fails, the first one is still in the database.
There are three ways to fix this, ordered by preference:
- Move
placeOrderto a separate bean, likeOrderPlacementService, and inject it. - Add
@TransactionaltoplaceOrdersitself if you want the entire batch to be one transaction. - Self-inject using
@Lazy. This works but is harder to read; use it only when you can’t split the beans.
@Service
public class OrderBatchService {
private final OrderService orderService; // this is a proxy
public OrderBatchService(OrderService orderService) {
this.orderService = orderService;
}
public void placeOrders(List<String> customers) {
customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
}
}
Now each call goes through the proxy, and the log prints Transaction active: true.
5. Pitfall 2: Private or Final Methods
Starting with Spring Framework 6.0, when using class-based proxies (CGLIB), @Transactional works on public, protected, and package-private methods. However, there are still two cases where it doesn’t work:
- Private methods: a subclass can’t override them, so the proxy can’t intercept the call.
- Final methods: CGLIB can’t override them either. The call goes straight to the original code, with no transaction.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // NO transaction
return orderRepository.save(new Order(customer, amount));
}
Spring doesn’t throw an error in this case. You only see a single log line at INFO or DEBUG level, which is easy to miss.
Note: Kotlin closes (
final) every class and method by default. Use thekotlin-springplugin (all-open) to automatically open Spring beans.
6. Pitfall 3: Checked Exceptions Don’t Cause Rollback
By default, Spring only rolls back when it encounters a RuntimeException or Error. Checked exceptions let the transaction commit.
public class PaymentDeclinedException extends Exception {
public PaymentDeclinedException(String message) {
super(message);
}
}
@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
Order order = orderRepository.save(new Order(customer, amount));
if (amount.signum() <= 0) {
throw new PaymentDeclinedException("Invalid amount");
}
return order;
}
Calling with amount = -5 still throws the exception to the caller, but the negative order has already been saved. To fix it, declare explicitly:
@Transactional(rollbackFor = PaymentDeclinedException.class)
Many teams use the convention rollbackFor = Exception.class for the entire service. A simpler approach is to design business exceptions that inherit from RuntimeException. The opposite also works: noRollbackFor = SomeRuntimeException.class keeps the commit even if an error occurs.
7. Pitfall 4: Swallowing Exceptions and UnexpectedRollbackException
This is the hardest pitfall to understand. Consider a service that reserves inventory:
@Service
public class InventoryService {
@Transactional // default propagation: REQUIRED
public void reserve(String sku) {
throw new IllegalStateException("Out of stock: " + sku);
}
}
And OrderService calls it, then catches the error with the intention of “if reservation fails, still create the order”:
@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
try {
inventoryService.reserve(sku);
} catch (IllegalStateException ex) {
System.out.println("Ignoring inventory error: " + ex.getMessage());
}
return order;
}
The result:
Ignoring inventory error: Out of stock: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
Here’s what happens. reserve participates in the same physical transaction as placeOrderWithInventory. When the exception goes through the proxy of InventoryService, Spring marks the transaction as rollback-only. Your catch outside doesn’t clear that mark. At commit time, Spring detects the mark, rolls back, and throws UnexpectedRollbackException to signal that the caller thought it was committing, but actually wasn’t.
How to fix it depends on your intent:
- If an inventory error should cancel the entire order, don’t
catch. Let the exception bubble up. - If
reserveis independent, give it its own transaction usingREQUIRES_NEW(see section 8). - If
reservedoesn’t need to write to the database, remove@Transactionalfrom it.
Note: Swallowing an exception inside the same method doesn’t mark the transaction. The mark is only set when an exception goes through a
@Transactionalproxy.
8. Propagation REQUIRES_NEW: Useful but Costly
A reasonable case for REQUIRES_NEW is writing an audit log. The log should be persisted even if the main business transaction rolls back:
@Service
public class AuditService {
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action) {
auditLogRepository.save(new AuditLog(action));
}
}
Spring suspends the outer transaction and opens a new one on a different connection. When record finishes, that transaction commits independently.
The price you pay:
- Each request holds two connections at once. Under high load, the pool (HikariCP defaults to 10 connections) can get exhausted and cause deadlocks at the pool level: every thread holds one connection and waits for a second.
- The new transaction doesn’t see uncommitted data from the outer transaction. If
AuditLoghas a foreign key to theOrderjust created, you’ll get an error or be locked waiting.
Use REQUIRES_NEW only when you truly need an independent commit, not as a “quick fix” for every rollback-only error.
9. readOnly and Long-Running Transactions
@Transactional(readOnly = true) tells Hibernate to set flush mode to MANUAL and skip dirty checking. For queries returning many entities, this significantly reduces memory and CPU usage:
@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
return orderRepository.findAll().stream()
.filter(o -> o.getCustomerName().equals(customer))
.toList();
}
Two things to remember:
readOnlyis an optimization hint, not a protection mechanism. Depending on the driver and database, a nativeUPDATEstatement can still run.- Don’t call external HTTP APIs, send emails, or
Thread.sleepinside a transaction. The connection is held the entire time, as are locks on written rows. Fetch data from outside first, then open a short transaction to write.
10. Testing Rollback Behavior
Another pitfall lies in tests. If you put @Transactional on your test class, everything runs in the test’s transaction and gets rolled back at the end. This hides whether the service actually commits or rolls back correctly. For transaction tests, remove this annotation and clean up the data yourself:
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@AfterEach
void cleanUp() {
orderRepository.deleteAll();
}
@Test
void whenPaymentFails_thenOrderIsRolledBack() {
assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
.isInstanceOf(IllegalStateException.class);
assertThat(orderRepository.count()).isZero();
}
@Test
void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
.isInstanceOf(UnexpectedRollbackException.class);
assertThat(orderRepository.count()).isZero();
}
@Test
void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
.isInstanceOf(PaymentDeclinedException.class);
assertThat(orderRepository.count()).isEqualTo(1);
}
}
The third test “locks in” the default behavior with checked exceptions. When you add rollbackFor, this test will fail, and you change the expectation to isZero(). This is a good way to document the rollback decision right in the code.
11. Conclusion
@Transactional looks simple, but all its behavior depends on the proxy. Here’s a summary of the main pitfalls:
- Self-invocation: calling through
thisbypasses the proxy. Split the beans. - Private or
finalmethods: not intercepted, and Spring doesn’t report an error. - Checked exceptions: commit by default. Use
rollbackForor useRuntimeException. - Rollback-only: an exception going through a proxy inside will mark the entire transaction;
catchoutside can’t save it. REQUIRES_NEW: costs an extra connection, use only when you need an independent commit.- Tests with
@Transactional: hide the real commit/rollback behavior.
When in doubt, enable TransactionInterceptor logging at TRACE level and check TransactionSynchronizationManager.isActualTransactionActive(). These two tools tell you exactly whether a transaction actually exists.