Have you ever experienced a situation where an error occurred during order processing, but the inventory was still decremented? Or perhaps you’ve encountered issues where @Transactional didn’t trigger a rollback, or transaction boundaries were unclear across multiple method calls.

Transaction management is essential knowledge for enterprise application development. This article walks you through everything step by step — from how Spring Boot’s @Transactional annotation works under the hood, to choosing the right propagation and isolation levels, to common failure patterns seen in real-world projects.

How @Transactional Works and Its Default Settings

The @Transactional annotation, when applied to a method or class, tells Spring Boot to automatically manage transactions for you.

The basic behavior is as follows:

  • A transaction starts before the method executes
  • The transaction commits if the method completes normally
  • The transaction rolls back if an exception is thrown

However, by default, rollback only occurs for RuntimeException (unchecked exceptions). Be aware that checked exceptions result in a commit.

Also, in Spring Boot, you do not need to configure @EnableTransactionManagement — it is enabled automatically.

Here is a minimal implementation example:

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 here will trigger a rollback
        if (order.getAmount() < 0) {
            throw new IllegalArgumentException("Invalid amount");
        }
    }
}

An important point: @Transactional operates via Spring AOP proxy-based mechanisms. This means it only takes effect on calls from outside the class — it has no effect on calls within the same class (self-invocation). We will cover this in detail later.

For an explanation of how AOP works, see this article.

Transaction Propagation Levels: Types and When to Use Them

Transaction propagation levels control how a method behaves when an existing transaction is already active. Spring Boot provides seven propagation levels.

REQUIRED (Default)

The most common choice. Joins an existing transaction if one exists; otherwise, creates a new one.

@Transactional(propagation = Propagation.REQUIRED)
public void processOrder(Order order) {
    // Joins an existing transaction if present, otherwise creates a new one
}

REQUIRES_NEW

Always starts a new transaction. Commits and rollbacks are independent of any outer transaction.

This is useful for audit logs or notification processing, where you want to preserve a log record even if the main operation fails.

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        
        // Notification runs in its own independent transaction
        // The notification log is preserved even if order processing fails
        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) {
        // Saves the notification log in a new transaction
        // Note: if this method itself fails, it does not affect the outer transaction
        // (since propagated exceptions will roll back the outer transaction too,
        // wrap with try-catch if needed)
    }
}

NESTED

Creates a nested transaction. Only the inner transaction can be rolled back independently.

This requires the database to support savepoints. Most major RDBMSs (PostgreSQL, MySQL/MariaDB, Oracle, H2, etc.) support this, but some databases may not.

SUPPORTS

Joins a transaction if one exists; otherwise, runs without one. Useful for read-only operations.

NOT_SUPPORTED

Suspends any active transaction and runs outside of it. Use this when you don’t want a long-running operation to hold a database connection.

MANDATORY

Requires an existing transaction. Throws an exception if no transaction is active.

NEVER

Throws an exception if a transaction exists. Explicitly signals that the operation must not run within a transaction.

In practice, understanding REQUIRED and REQUIRES_NEW is sufficient for the vast majority of use cases.

Transaction Isolation Levels: Differences and Selection Criteria

Isolation levels control data consistency when multiple transactions execute concurrently. Stricter isolation improves data consistency but degrades performance.

READ_UNCOMMITTED

Allows reading uncommitted data (Dirty Read). Rarely used in practice.

READ_COMMITTED

Only reads committed data. This is the default for most databases. Within the same transaction, the result of a read can change if another transaction commits in between (Non-Repeatable Read).

REPEATABLE_READ

Guarantees that repeating the same query within a transaction returns the same result. However, range queries may still return different results if another transaction inserts rows in between (Phantom Read). MySQL’s InnoDB prevents Phantom Reads as well.

Use this when strict consistency is required, such as in monetary calculations.

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(Long orderId, BigDecimal amount) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    
    // Read the order amount at this point
    BigDecimal currentAmount = order.getAmount();
    
    // Other processing...
    
    // Re-reading still returns the same amount
    order = orderRepository.findById(orderId).orElseThrow();
    // Matches currentAmount
}

SERIALIZABLE

The strictest isolation level. Guarantees complete isolation, but has a significant performance impact.

Guidelines for Practical Use

The default (READ_COMMITTED) is sufficient in most cases. Specifying Isolation.DEFAULT in Spring Boot uses the default isolation level of your data source or database.

Only consider REPEATABLE_READ or higher when strict consistency is required, such as for monetary calculations or inventory management. Keep in mind that higher isolation levels increase lock contention.

Common Rollback Failure Patterns and How to Fix Them

Here are frequently encountered problems in real projects, along with their solutions.

Failure Pattern 1: Checked Exceptions Do Not Trigger Rollback

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

@Transactional
public void processOrder(Order order) throws Exception {
    orderRepository.save(order);
    
    // Throwing a checked exception → does NOT roll back!
    if (order.getAmount() < 0) {
        throw new Exception("Invalid amount");
    }
}

Fix: Explicitly specify the exception types using the rollbackFor attribute.

@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception {
    orderRepository.save(order);
    
    // Checked exceptions now trigger a rollback
    if (order.getAmount() < 0) {
        throw new Exception("Invalid amount");
    }
}

To roll back on all exceptions, specifying rollbackFor = Exception.class is the safest approach.

Failure Pattern 2: Self-Invocation Within the Same Class

Since @Transactional is proxy-based, calling a method within the same class bypasses the proxy and the transaction is not applied.

@Service
public class OrderService {

    public void processOrder(Order order) {
        // Calling a method in the same class
        // Does not go through the proxy, so @Transactional has no effect!
        saveOrder(order);
    }

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

Fix: Split the logic into a separate Service class.

@Service
public class OrderService {

    private final OrderPersistenceService persistenceService;

    public void processOrder(Order order) {
        // Calling a method on a different class → goes through the proxy
        persistenceService.saveOrder(order);
    }
}

@Service
public class OrderPersistenceService {

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

Failure Pattern 3: Applying @Transactional to a Private Method

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

Use Logging to Verify Transaction Boundaries During Troubleshooting

Configuring logging is an effective way to check whether transactions are behaving as intended.

logging.level.org.springframework.transaction=DEBUG

With this setting, log messages such as “Creating new transaction” and “Participating in existing transaction” will appear, making transaction boundaries visible.

For details on exception handling, see How to Implement Exception Handling in Spring Boot REST APIs.

Performance Optimization with the readOnly Attribute

Setting readOnly=true communicates a read-only hint to the database.

@Transactional(readOnly = true)
public List<Order> searchOrders(String keyword) {
    return orderRepository.findByKeyword(keyword);
}

The effects of readOnly=true include:

  • Hibernate skips dirty checking and sets FlushMode to MANUAL
  • The database may apply read-only optimizations
  • Reduced memory usage and improved performance

Use this actively for operations that do not involve writes, such as search queries or report generation.

Note that if you attempt a write operation with readOnly=true, the behavior varies depending on the database and JPA implementation. In most cases a TransactionSystemException is thrown, but in some environments the write may be silently ignored rather than causing an error.

For more on data operations with JPA, see How to Map Entity Relationships with JPA in Spring Boot.

Visualizing Transaction Boundaries and Verifying Behavior

Here are techniques for verifying that transactions behave as intended in real-world projects.

Verification via Log Configuration

Add the following to application.properties:

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

From the logs, you can confirm:

  • “Creating new transaction”: A new transaction has started
  • “Participating in existing transaction”: Joined an existing transaction
  • “Committing JPA transaction”: Committed
  • “Rolling back JPA transaction”: Rolled back

Verification via Test Code

In the Spring Test framework, @Transactional applied to a test class or method operates in a different context from application code. By default, test transactions are rolled back, so tests do not interfere with each other’s data.

If you need to actually commit and inspect the database state (e.g., verifying the real DB state in an integration test), 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;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @Transactional
    @Commit  // Use this when you need to verify the actual DB state in an integration test
    void testCreateOrder() {
        Order order = new Order();
        order.setAmount(new BigDecimal("1000"));
        
        orderService.createOrder(order);
        
        // Data is actually persisted to the database
    }

    @Test
    @Transactional
    void testTransactionPropagation() {
        // Verify REQUIRES_NEW behavior
        // Rolled back by default, so tests do not interfere with each other
    }
}

For more on testing, see How to Write Spring Boot Tests with JUnit and Mockito.

Best Practices for Transaction Design in Real Projects

Using a typical three-layer architecture as an example, here are guidelines for transaction design that balances maintainability and performance.

Transaction Placement in a Typical Three-Layer Architecture

The standard practice is to place transaction boundaries in the Service layer. Dividing responsibilities across layers as shown below enables a highly maintainable application.

// Controller layer: no transactions
@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) {
        // Business logic executes here
        // Transaction boundary spans the start to end of this method
        return orderRepository.save(order);
    }
}

// Repository layer: no transaction annotations (managed by the Service)
public interface OrderRepository extends JpaRepository<Order, Long> {
}

This architecture offers the following benefits:

  • No transactions in the Controller layer: Wrapping the entire HTTP request handling in a transaction tends to increase processing time and hold database connections for longer than necessary
  • Business logic and transaction management are centralized in the Service layer: Operations combining multiple Repositories can have their transaction boundaries properly controlled at the Service layer
  • Repository layer does not manage its own transactions: By letting the Service layer manage them, multiple Repository operations can be grouped into a single transaction

Other Important Guidelines

  • Keep transactions short: Long-running transactions hold locks for an extended period and block other operations. Keep the transaction scope as small as necessary
  • Avoid specifying explicit attributes when defaults are sufficient: To keep code simple, @Transactional alone is enough unless there are special requirements
  • Revisit the design when complex transaction control is needed: If multiple transaction boundaries are deeply interleaved, consider splitting business logic or extracting operations into asynchronous processing
// Simple case
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);
}

// Explicit attributes only when special requirements exist
@Transactional(
    propagation = Propagation.REQUIRES_NEW,
    isolation = Isolation.REPEATABLE_READ,
    rollbackFor = Exception.class
)
public void processPayment(Payment payment) {
    // Strict operations such as monetary calculations
}

Summary

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

Key takeaways:

  • By default, only RuntimeException triggers a rollback. Use rollbackFor for checked exceptions
  • Understanding REQUIRED and REQUIRES_NEW propagation levels is sufficient for most real-world scenarios
  • The default isolation level (READ_COMMITTED) is fine in most cases. Only consider REPEATABLE_READ or higher when strict consistency is required
  • Transactions do not apply to self-invocation. Solve this by splitting into separate classes
  • Use readOnly=true to optimize performance for read-only operations
  • The standard practice is to place transaction boundaries in the Service layer

A solid understanding of transaction management allows you to build maintainable applications while preserving data integrity.