Have you ever lost inventory on a failed order, or had @Transactional not roll back the way you expected?
Have you ever had an error occur during order processing, yet the inventory count still decreased? Or run into a case where @Transactional didn’t roll back even though it was applied?
In this article, we’ll walk through how Spring Boot’s @Transactional annotation works under the hood, how to choose between propagation and isolation levels, and the typical failure patterns you’ll encounter in real-world development.
To get straight to the point: @Transactional is an annotation that, when applied to a method or class, lets Spring Boot automatically handle starting, committing, and rolling back transactions for you. Let’s go through the mechanism and its pitfalls step by step.
Basic behavior and default settings of @Transactional
The @Transactional annotation enables Spring Boot to automatically manage transactions when applied to a method or class. It starts a transaction before the method executes, commits it if the method completes normally, and rolls it back if an exception is thrown.
However, by default, rollback only happens for RuntimeException (unchecked exceptions). Be careful, because a checked exception will still result in a commit. Note that with Spring Boot, you don’t need to configure @EnableTransactionManagement — it’s enabled automatically via auto-configuration.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
// A RuntimeException thrown during processing triggers a rollback
if (order.getAmount() < 0) {
throw new IllegalArgumentException("Invalid amount");
}
}
}
An important point: @Transactional operates via a Spring AOP proxy-based mechanism. This means it only takes effect when called from outside the class — calling the method from within the same class (self-invocation) has no effect. We’ll cover this in more detail later. This article explains how AOP works in more depth.
Types of transaction propagation levels and when to use them
Transaction propagation levels control how a method behaves when an existing transaction is already present. There are seven types, but in practice, understanding REQUIRED and REQUIRES_NEW covers most cases.
REQUIRED (default)
The most common choice. It joins an existing transaction if one exists, or creates a new one if not.
@Transactional(propagation = Propagation.REQUIRED)
public void processOrder(Order order) {
// Joins the existing transaction if present, otherwise creates a new one
}
REQUIRES_NEW
Always starts a new transaction. Since it commits or rolls back independently of the outer transaction, it’s useful for things like audit logs or notification processing, where you want the record to persist even if the main process fails.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
// Notification processing runs in its own independent transaction
// Even if order processing fails, the notification log remains
notificationService.sendNotification(order);
// Even if an exception is thrown after this point, the notification log is already committed
validateOrder(order);
}
}
@Service
public class NotificationService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendNotification(Order order) {
// Save the notification log in a new transaction
// Note: if an exception thrown here propagates outward, it will also roll back
// the outer transaction, so catch it with try-catch if needed
}
}
Note that for processing you want to run “after the main process commits,” designing it as event-driven with @TransactionalEventListener instead of REQUIRES_NEW is also a valid approach. For details, see How to decouple modules in Spring Boot with ApplicationEvent.
Other propagation levels
- NESTED: Creates a nested transaction using a savepoint, allowing only the inner part to be rolled back. This requires database-level savepoint support (PostgreSQL, MySQL/MariaDB, Oracle, H2, etc. support this).
- SUPPORTS: Joins a transaction if one exists, but runs without one if not.
- NOT_SUPPORTED: Suspends any existing transaction and runs without one. Useful for long-running operations where you don’t want to hold a connection open.
- MANDATORY: Requires an existing transaction; throws an exception if none exists.
- NEVER: Throws an exception if a transaction already exists.
Differences in transaction isolation levels and how to choose one
Isolation levels control data consistency when multiple transactions execute concurrently. The stricter the level, the higher the consistency, but the lower the performance.
- READ_UNCOMMITTED: Can read uncommitted data (Dirty Read). Rarely used in practice.
- READ_COMMITTED: Can only read committed data. This is the default for many databases, but the same query can return different results within the same transaction (Non-Repeatable Read).
- REPEATABLE_READ: Guarantees the same query returns the same results within a single transaction. Phantom Reads, where results change due to inserts into a range search, technically remain possible under this spec, but MySQL’s InnoDB prevents this as well.
- SERIALIZABLE: Guarantees complete isolation, but has a significant impact on performance.
For operations requiring strict consistency, such as monetary calculations, REPEATABLE_READ is an option.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(Long orderId, BigDecimal amount) {
Order order = orderRepository.findById(orderId).orElseThrow();
// Read the order's amount at this point
BigDecimal currentAmount = order.getAmount();
// Other processing...
// Reading again is guaranteed to return the same amount
order = orderRepository.findById(orderId).orElseThrow();
// Matches currentAmount
}
The practical guideline is simple: the default (READ_COMMITTED) is usually sufficient. Specifying Isolation.DEFAULT uses whatever default isolation level your database uses. Keep in mind that raising the isolation level increases lock contention, so only consider REPEATABLE_READ or higher when strict consistency is actually required.
Conditions for rollback and typical failure patterns where it doesn’t happen
There are mainly two patterns where rollback fails to take effect.
Throwing a checked exception
By default, only RuntimeException (unchecked exceptions) trigger a rollback — a checked exception left as-is will result in a commit. If you define your business exceptions as checked exceptions, explicitly specify rollbackFor.
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) throws InsufficientStockException {
orderRepository.save(order);
if (order.getStock() < order.getAmount()) {
throw new InsufficientStockException("Insufficient stock");
}
}
Calling via self-invocation
Calling a @Transactional method from within the same class has no effect, because the call doesn’t go through the proxy. This is resolved by splitting the caller and callee into separate Beans (separate classes).
@Service
public class OrderService {
private final OrderTransactionService txService;
public void handleOrder(Order order) {
txService.createOrder(order); // Effective because it goes through a separate Bean
}
}
What is a transaction boundary, and where should you draw it?
Most of these failure patterns, when you trace them back, come down to “a design mistake in where the transaction boundary was drawn.”
A transaction boundary is the range from when a transaction starts until it’s committed or rolled back. Within this range, database operations are either “all successful” or “all as if they never happened.”
The guideline for designing boundaries is simple: draw a single boundary around the unit that should, as a matter of business logic, “succeed together or fail together.” In implementation terms, the boundary unit corresponds to a public method on the Service layer that represents a use case.
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
// The entire "place order" use case is a single boundary
orderRepository.save(order); // Save the order
stockRepository.decrease(order); // Reserve the inventory
// If either fails, both are rolled back
}
}
There are three common anti-patterns.
- Boundaries that are too fine-grained: A pattern where the Service layer lacks
@Transactional. Since Spring Data JPA’s Repository methods are protected by@TransactionalonSimpleJpaRepository, they execute as short transactions scoped to a single method call. In the example above, if an exception occurs during inventory reservation, the order remains saved — leading to the exact “error occurred, but only the inventory decreased” scenario mentioned at the start. For use cases involving multiple updates, always draw the boundary at the Service layer. - Boundaries that are too broad: A pattern where the entire Controller layer or an entire batch job is wrapped in a single transaction. This brings external API calls and file I/O inside the boundary, holding connections and locks for extended periods and degrading throughput. As a rule, keep external calls outside the transaction.
- Boundaries that don’t match intent: A pattern where, due to self-invocation, the transaction isn’t actually started at the location you intended to draw the boundary. This corresponds to failure pattern 2 from the previous section.
Performance optimization with the readOnly attribute
Setting readOnly=true lets you pass a read-only hint through to the database.
@Transactional(readOnly = true)
public List<Order> searchOrders(String keyword) {
return orderRepository.findByKeyword(keyword);
}
In Hibernate, this skips dirty checking and sets the FlushMode to MANUAL, which can reduce memory usage and improve performance. Since the cost of dirty checking is proportional to the number of managed entities, the effect is especially significant for operations that load large numbers of entities, such as search result listings or report generation. For a handful of reads, the difference is barely noticeable, but since it leaves a clear signal in the code that “this method doesn’t modify data,” we recommend applying it consistently to all read-only methods.
A common pattern is to apply @Transactional(readOnly = true) at the class level and override it with an individual @Transactional only on the update methods. Since read-only becomes the default, this structurally prevents forgetting to add readOnly.
@Service
@Transactional(readOnly = true)
public class OrderService {
public List<Order> findOrders() {
// The class-level readOnly=true applies here
return orderRepository.findAll();
}
@Transactional // Method-level takes precedence, allowing writes
public Order createOrder(Order order) {
return orderRepository.save(order);
}
}
What happens if you perform an update while readOnly=true is set
You can’t simply assume “setting readOnly guarantees updates will always error out” — depending on the code path, an update can silently disappear instead. This is the root cause behind confusing situations like “I set readOnly but the update still went through” or, conversely, “it only errors out in production.” The actual behavior depends on the combination of your JPA implementation and database (JDBC driver).
- Hibernate layer: Since FlushMode is MANUAL, even if you modify an entity, dirty checking won’t generate an UPDATE statement. The key point is that this fails silently rather than throwing an error — a classic pattern that’s easy to miss in tests.
- Explicit updates via JPQL/native queries: These bypass dirty checking and will attempt to execute. Whether they get blocked depends on the database-side configuration.
- Database layer: In PostgreSQL, the transaction enters READ ONLY mode, so executing an UPDATE results in a “cannot execute UPDATE in a read-only transaction” error. MySQL (Connector/J) also throws an exception when attempting to update over a read-only connection.
It’s safest to treat readOnly as an optimization hint rather than a safety mechanism, and to keep read operations and update operations separated at the method (or class) level.
The pitfall of combining readOnly with propagation levels
The readOnly setting is only actually honored when that method starts a new transaction. If the method joins an existing transaction under the default REQUIRED propagation, the inner method’s readOnly setting is ignored, and the outer transaction’s setting takes over instead.
@Service
public class ReportService {
// Read-only transaction when called on its own
@Transactional(readOnly = true)
public List<Order> findRecentOrders() {
return orderRepository.findTop100ByOrderByCreatedAtDesc();
}
}
@Service
public class OrderService {
@Transactional // Write transaction
public void closeDailyOrders() {
// Because this "joins" the existing write transaction,
// findRecentOrders()'s readOnly=true has no effect
List<Order> orders = reportService.findRecentOrders();
// ...
}
}
Conversely, if a method that performs an update joins a read-only transaction under REQUIRED, it will attempt to update while still in read-only mode and fail. Remembering that “whether readOnly actually takes effect is determined by the method that started the transaction, not the method you’re looking at” will help you catch this kind of bug early.
For more on working with data via JPA, see How to map JPA Entity relationships in Spring Boot.
How to visualize and verify transaction boundary behavior
Logging is an effective way to verify that transactions are behaving as intended. Configure the following in application.properties:
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.hibernate.SQL=DEBUG
This outputs logs like “Creating new transaction” (new start), “Participating in existing transaction” (joining an existing one), “Committing JPA transaction,” and “Rolling back JPA transaction,” letting you verify that boundaries are drawn where you intended.
Verifying in test code
@Transactional applied to a test class or test method operates in a different context from application code. In tests, it rolls back by default, so data doesn’t interfere between tests. If you want to actually commit and check the resulting database state, use @Commit or @Rollback(false).
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Test
@Transactional
@Commit // Use this when you want to verify the actual DB state in an integration test
void testCreateOrder() {
Order order = new Order();
order.setAmount(new BigDecimal("1000"));
orderService.createOrder(order);
// Actually gets saved to the database
}
}
For how to leverage this automatic rollback in Repository layer tests, see How to write Repository layer slice tests with Spring Boot’s @DataJpaTest; for general testing practices, see How to write Spring Boot tests with JUnit and Mockito.
Best practices for transaction design in real-world development
Placing transaction boundaries at the Service layer is the standard approach.
// Controller layer: no transaction
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody Order order) {
Order created = orderService.createOrder(order);
return ResponseEntity.ok(created);
}
}
// Service layer: the center of transaction management
@Service
public class OrderService {
private final OrderRepository orderRepository;
@Transactional
public Order createOrder(Order order) {
// The transaction boundary spans from the start to the end of this method
return orderRepository.save(order);
}
}
// Repository layer: no transaction specified (managed by the Service)
public interface OrderRepository extends JpaRepository<Order, Long> {
}
Placing it at the Controller layer turns the entire HTTP request into a transaction, holding connections open for too long; placing it at the Repository layer makes it impossible to bundle multiple operations into a single boundary.
As additional guidelines, keep transactions short, and stick to a plain @Transactional with no extra attributes unless there’s a specific requirement. If you find multiple transaction boundaries becoming tangled together, that’s a sign you should consider splitting up the business logic or offloading part of it to asynchronous processing.
// Simple case
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
// Only specify explicitly when there's a special requirement
@Transactional(
propagation = Propagation.REQUIRES_NEW,
isolation = Isolation.REPEATABLE_READ,
rollbackFor = Exception.class
)
public void processPayment(Payment payment) {
// Strict processing such as monetary calculations
}
Summary
In this article, we covered transaction management using Spring Boot’s @Transactional annotation.
- By default, rollback only occurs for
RuntimeException. Checked exceptions requirerollbackFor - Understanding REQUIRED and REQUIRES_NEW propagation levels is sufficient for most real-world cases
- The default isolation level (READ_COMMITTED) is usually fine
- Transactions don’t take effect with self-invocation. Resolve this by splitting into a separate class
- Use
readOnly=trueto optimize performance for read-only operations - Place transaction boundaries at the Service layer, with one transaction per use case
With a solid understanding of these principles, you can build applications that balance data consistency with maintainability.