Have you ever had an order-processing error occur, only to find that the inventory was still decremented? Or run into a situation where a method annotated with @Transactional simply didn’t roll back?

This article walks step by step through how Spring Boot’s @Transactional annotation works under the hood, how to choose between propagation and isolation levels, and the failure patterns that come up most often in real-world projects.

Basic Behavior and Default Settings of @Transactional

The @Transactional annotation is a mechanism that lets Spring Boot manage transactions automatically when you place it on a method or class. A transaction is started before the method runs, committed if the method completes normally, and rolled back if an exception is thrown.

Be aware, however, that by default rollback only happens for RuntimeException (unchecked exceptions). Checked exceptions result in a commit, so take care. Also note that in Spring Boot you do not need to configure @EnableTransactionManagement; auto-configuration enables it for you.

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);
        // 処理中にRuntimeExceptionが発生するとロールバック
        if (order.getAmount() < 0) {
            throw new IllegalArgumentException("金額が不正です");
        }
    }
}

One important point is that @Transactional works on a proxy basis via Spring AOP. As a result, it only takes effect for calls coming from outside the class; method calls within the same class (self-invocation) have no effect. We’ll cover this in detail later. How AOP works is explained in this article.

Transaction Propagation Levels: Types and When to Use Them

The transaction propagation level controls how a method behaves when a transaction already exists. There are seven levels, but in practice understanding REQUIRED and REQUIRES_NEW covers almost every case.

REQUIRED (Default)

This is the most common choice. If a transaction already exists, the method joins it; otherwise, a new one is created.

@Transactional(propagation = Propagation.REQUIRED)
public void processOrder(Order order) {
    // 既存トランザクションがあれば参加、なければ新規作成
}

REQUIRES_NEW

This always starts a new transaction. Because it is committed or rolled back independently of the outer transaction, it is useful for things like audit logs or notifications, where you want the record to persist even if the main processing fails.

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        
        // 通知処理は独立したトランザクションで実行
        // 注文処理が失敗しても通知ログは残る
        notificationService.sendNotification(order);
        
        // この後で例外が発生しても、通知ログはコミット済み
        validateOrder(order);
    }
}

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Order order) {
        // 新しいトランザクションで通知ログを保存
        // 注意: ここで発生した例外が伝播すると外側もロールバックされるため、
        // 必要に応じてtry-catchで例外を捕捉する
    }
}

For work that should run “after the main processing has committed,” an event-driven design using @TransactionalEventListener is also a strong alternative to REQUIRES_NEW. See How to Decouple Modules with ApplicationEvent in Spring Boot for details.

Other Propagation Levels

  • NESTED: Creates a nested transaction using a savepoint, allowing only the inner part to be rolled back. The database must support savepoints (PostgreSQL, MySQL/MariaDB, Oracle, H2, and others do).
  • SUPPORTS: Joins a transaction if one exists, but runs without one otherwise.
  • NOT_SUPPORTED: Suspends the transaction and runs outside of it. Use this when you don’t want a long-running process to hold a connection.
  • MANDATORY: Requires an existing transaction and throws an exception if none exists.
  • NEVER: Throws an exception if a transaction exists.

Transaction Isolation Levels: Differences and Selection Criteria

The isolation level controls data consistency when multiple transactions run concurrently. Stricter levels increase consistency but reduce performance.

  • READ_UNCOMMITTED: Can read uncommitted data (Dirty Read). Almost never used in practice.
  • READ_COMMITTED: Can only read committed data. This is the default for many databases, but read results can change within the same transaction (Non-Repeatable Read).
  • REPEATABLE_READ: Guarantees that the same query returns the same result within a single transaction. Per the specification, Phantom Reads (where inserts change the results of a range query) can still occur, but MySQL’s InnoDB prevents these as well.
  • SERIALIZABLE: Guarantees complete isolation, but has a significant performance impact.

For processing that requires 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();
    
    // この時点でorderの金額を読み取る
    BigDecimal currentAmount = order.getAmount();
    
    // 他の処理...
    
    // 再度読み取っても同じ金額が保証される
    order = orderRepository.findById(orderId).orElseThrow();
    // currentAmountと一致する
}

The practical guideline is simple: the default (READ_COMMITTED) is usually sufficient. Specifying Isolation.DEFAULT uses the default isolation level of the database you are working with. Keep in mind that raising the isolation level increases lock contention, so consider REPEATABLE_READ or higher only when strict consistency is truly required.

Typical Failure Patterns Where Rollback Doesn’t Work, and How to Fix Them

Here are the problems most commonly encountered in practice, along with their solutions.

Failure Pattern 1: No Rollback on Checked Exceptions

By default, rollback only happens for RuntimeException (unchecked exceptions). Checked exceptions result in a commit.

@Transactional
public void processOrder(Order order) throws Exception {
    orderRepository.save(order);
    
    // checked例外をスロー → ロールバックされない!
    if (order.getAmount() < 0) {
        throw new Exception("金額が不正です");
    }
}

The fix is to specify it explicitly with the rollbackFor attribute.

@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception {
    orderRepository.save(order);
    
    // checked例外でもロールバックされる
    if (order.getAmount() < 0) {
        throw new Exception("金額が不正です");
    }
}

This asymmetric behavior is a convention inherited from the EJB era. The design assumes that checked exceptions are “recoverable business exceptions the caller can handle (safe to commit),” while unchecked exceptions are “unrecoverable system exceptions (should roll back).” In practice this distinction often doesn’t match reality, so when in doubt, explicitly specifying rollbackFor = Exception.class is the safe choice.

Failure Pattern 2: Method Calls Within the Same Class (Self-Invocation)

Because @Transactional works on a proxy basis, transactions are not applied to calls made from within the same class.

@Service
public class OrderService {

    public void processOrder(Order order) {
        // 同じクラス内のメソッド呼び出し
        // プロキシを経由しないため、@Transactionalが効かない!
        saveOrder(order);
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

The fix is to split the method out into a separate Service class.

@Service
public class OrderService {

    private final OrderPersistenceService persistenceService;

    public void processOrder(Order order) {
        // 別クラスのメソッド呼び出し → プロキシを経由する
        persistenceService.saveOrder(order);
    }
}

@Service
public class OrderPersistenceService {

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
    }
}

Note that this self-invocation limitation is not unique to @Transactional; it applies to all proxy-based annotations, including @Async and @Retryable. The same pitfall in retry processing is covered in Implementing Retry Logic with @Retryable in Spring Boot.

Failure Pattern 3: Placing @Transactional on a Private Method

Proxies only apply to public methods. Placing @Transactional on a private method has no effect.

Rollback and exception design are two sides of the same coin. For details on exception handling, see How to Implement Exception Handling in Spring Boot REST APIs.

What Is a Transaction Boundary, and Where Should You Draw It?

Most of these failure patterns ultimately come down to “a mistake in transaction boundary design.”

A transaction boundary is the scope from the point a transaction starts until it is committed or rolled back. Database operations within this scope either “all succeed” or are “all undone.”

The guideline for boundary design is simple: draw one boundary around the unit of work that, from a business perspective, should succeed or fail as a whole. In implementation terms, the public method on the Service layer that represents a use case becomes the unit of the boundary.

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        // 「注文確定」というユースケース全体が1つの境界
        orderRepository.save(order);        // 注文の保存
        stockRepository.decrease(order);    // 在庫の引き当て
        // どちらかが失敗すれば両方ロールバックされる
    }
}

There are three common anti-patterns.

  • Boundaries that are too fine-grained: The pattern where the Service layer has no @Transactional. Spring Data JPA Repository methods are protected by the @Transactional on SimpleJpaRepository, so each executes in a short transaction scoped to a single method call. In the example above, if an exception occurs during stock allocation, the order remains saved, which leads to exactly the “error occurred but inventory was still decremented” situation mentioned at the beginning. For use cases involving multiple updates, always draw the boundary at the Service layer.
  • Boundaries that are too broad: The pattern where the Controller layer or an entire batch job is wrapped in a single transaction. External API calls and file I/O end up inside the boundary, holding connections and locks for long periods and degrading throughput. As a rule, keep external calls outside the transaction.
  • Boundaries that don’t match intent: The pattern where, due to self-invocation, a transaction is not actually started where you intended to draw the boundary. Failure Pattern 2 in the previous section is an example of this.

Performance Optimization with the readOnly Attribute

Setting readOnly=true passes a read-only hint 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 scales with the number of managed entities, the benefit is especially large for processes that load many entities, such as search result listings or report generation. For reads of just a few records, the difference is barely noticeable, but because it documents in code that “this method does not perform updates,” we recommend applying it uniformly to all read-only methods.

Another standard pattern is to place @Transactional(readOnly = true) at the class level and override it with a separate @Transactional only on the methods that write. Since read-only becomes the default, this structurally prevents forgetting to add readOnly.

@Service
@Transactional(readOnly = true)
public class OrderService {

    public List<Order> findOrders() {
        // クラスレベルのreadOnly=trueが適用される
        return orderRepository.findAll();
    }

    @Transactional  // メソッドレベルが優先され、書き込み可能
    public Order createOrder(Order order) {
        return orderRepository.save(order);
    }
}

What Happens If You Update Under readOnly=true

Confusion along the lines of “I set readOnly but the update went through anyway” or “it only fails in production” is common. The behavior is determined by the combination of the JPA implementation and the database (JDBC driver).

  • Hibernate layer: Because FlushMode is MANUAL, modifying an entity does not trigger an UPDATE statement via dirty checking. The key point is that it fails silently rather than raising an error, which is a classic pattern that is hard to catch in tests.
  • Explicit updates via JPQL/native queries: These do not go through dirty checking, so they 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. In MySQL (Connector/J), updates on a read-only connection also throw an exception.

In other words, you cannot assume “readOnly always errors on updates, so it’s safe.” Depending on the path, updates can silently disappear. Treat readOnly as an optimization hint rather than a safety mechanism, and reliably separate read and write operations at the method (or class) level.

The Pitfall of Combining readOnly with Propagation Levels

The readOnly setting is only actually applied when a new transaction is started by that method. If the method joins an existing transaction under the default REQUIRED, the inner method’s readOnly setting is ignored and the outer transaction’s settings carry over.

@Service
public class ReportService {

    // 単体で呼ばれれば読み取り専用トランザクション
    @Transactional(readOnly = true)
    public List<Order> findRecentOrders() {
        return orderRepository.findTop100ByOrderByCreatedAtDesc();
    }
}

@Service
public class OrderService {

    @Transactional  // 書き込みトランザクション
    public void closeDailyOrders() {
        // 既存の書き込みトランザクションに「参加」するため、
        // findRecentOrders()のreadOnly=trueは効かない
        List<Order> orders = reportService.findRecentOrders();
        // ...
    }
}

Conversely, if a method that performs updates joins a readOnly transaction under REQUIRED, it will attempt to update while still read-only and fail. Remembering that “whether readOnly is in effect is determined not by the method itself but by the method that started the transaction” will help you suspect this kind of bug early.

For data operations using JPA, see How to Map JPA Entity Relationships in Spring Boot for a detailed explanation.

Visualizing Transaction Boundaries and Verifying Behavior

To confirm that transactions are behaving as intended, logging configuration is effective. Set the following in application.properties.

logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.hibernate.SQL=DEBUG

Log messages such as “Creating new transaction” (new start), “Participating in existing transaction” (joining an existing one), “Committing JPA transaction,” and “Rolling back JPA transaction” will be output, letting you verify that boundaries are drawn where you intended.

Verifying in Test Code

@Transactional placed on a test class or test method behaves in a different context from application code. In tests, transactions are rolled back by default, so data does not interfere between tests. If you want to actually commit and inspect the 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  // 結合テストで実際のDB状態を確認したい場合に使用
    void testCreateOrder() {
        Order order = new Order();
        order.setAmount(new BigDecimal("1000"));
        
        orderService.createOrder(order);
        
        // データベースに実際に保存される
    }
}

For how to leverage this automatic rollback in Repository-layer tests, see How to Write Repository Slice Tests with @DataJpaTest in Spring Boot, and for testing in general, see How to Write Spring Boot Tests with JUnit and Mockito.

Best Practices for Transaction Design in Practice

The standard approach is to place transaction boundaries in the Service layer.

// Controller層: トランザクションなし
@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層: トランザクション管理の中心
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    @Transactional
    public Order createOrder(Order order) {
        // トランザクション境界はこのメソッドの開始から終了まで
        return orderRepository.save(order);
    }
}

// Repository層: トランザクション指定なし(Serviceで管理)
public interface OrderRepository extends JpaRepository<Order, Long> {
}

Placing it in the Controller layer turns the entire HTTP request into a transaction and holds connections for too long, while placing it in the Repository layer prevents you from grouping multiple operations into a single boundary.

As additional guidelines, keep transactions short, and unless there are special requirements, stick to a plain @Transactional with no attributes. If multiple transaction boundaries start to become tangled, that’s a sign to consider splitting up the business logic or extracting work into asynchronous processing.

// シンプルな場合
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);
}

// 特別な要件がある場合のみ明示
@Transactional(
    propagation = Propagation.REQUIRES_NEW,
    isolation = Isolation.REPEATABLE_READ,
    rollbackFor = Exception.class
)
public void processPayment(Payment payment) {
    // 金額計算など厳密な処理
}

Summary

This article covered transaction management using Spring Boot’s @Transactional annotation.

  • By default, rollback only happens for RuntimeException. Checked exceptions require rollbackFor
  • For propagation levels, understanding REQUIRED and REQUIRES_NEW is sufficient for practical work
  • For isolation levels, the default (READ_COMMITTED) is usually fine
  • Transactions do not take effect under self-invocation. Split into a separate class to resolve
  • Use readOnly=true on read-only operations for performance optimization
  • Place transaction boundaries in the Service layer, one transaction per use case

With a solid understanding of these concepts, you can build applications that achieve both data consistency and maintainability.