Skip to main content
  1. Languages/
  2. Java Guides/

Mastering Spring Boot Transaction Management: @Transactional & ACID Best Practices

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Data integrity is the non-negotiable bedrock of enterprise software. In the landscape of 2025, where microservices and distributed architectures dominate, the humble local database transaction remains the fundamental unit of reliability. If your local transactions are flaky, your distributed sagas don’t stand a chance.

For Java developers, particularly those using Spring Boot 3.x, the @Transactional annotation is the magic wand that manages data consistency. However, “magic” in software engineering is often a source of production bugs. Misunderstanding proxies, propagation levels, or isolation settings can lead to phantom reads, lost updates, and catastrophic data corruption.

In this guide, we will move beyond the basics. We will dissect how Spring manages transactions under the hood, explore the ACID properties in a practical context, and implement a robust transaction strategy using Java 21 and Spring Boot 3.


Prerequisites and Environment
#

To follow the code examples and performance tuning tips in this article, ensure you have the following environment set up:

  • Java Development Kit (JDK): Version 21 (LTS) or higher.
  • Spring Boot: Version 3.2+.
  • Build Tool: Maven or Gradle.
  • Database: A relational database (PostgreSQL recommended for production simulation, H2 for quick testing).

Dependency Configuration
#

We will focus on the spring-boot-starter-data-jpa which brings in Hibernate and the Transaction Manager.

Maven (pom.xml):

<dependencies>
    <!-- Core Data JPA and Transaction Support -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    
    <!-- H2 Database for demonstration -->
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Lombok for boilerplate reduction -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

1. ACID Properties: The Theoretical Foundation
#

Before writing code, we must strictly define what we are trying to achieve. The @Transactional annotation is essentially a declarative contract that guarantees ACID properties.

  1. Atomicity: The “all or nothing” rule. If a method updates three tables and fails on the third, the first two updates must be rolled back.
  2. Consistency: The database transitions from one valid state to another. Constraints (Foreign Keys, Unique checks) are never violated.
  3. Isolation: Concurrent transactions should not interfere with each other. This is the most complex property to tune.
  4. Durability: Once committed, the data survives a system crash.

2. Under the Hood: How @Transactional Works
#

Many senior developers encounter bugs because they treat @Transactional as a simple switch. In reality, Spring uses AOP (Aspect-Oriented Programming) and proxies.

When you annotate a method, Spring creates a proxy around your bean. This proxy intercepts the method call.

The Proxy Flow
#

sequenceDiagram participant C as Caller participant P as Spring Proxy participant TM as Transaction Manager participant S as Target Service participant DB as Database C->>P: call transferMoney() Note over P: Intercepts call P->>TM: createTransaction() TM->>DB: SET AUTOCOMMIT = FALSE P->>S: transferMoney() (Actual Logic) alt Success S-->>P: return result P->>TM: commit() TM->>DB: COMMIT P-->>C: return result else Exception Thrown S-->>P: throw RuntimeException P->>TM: rollback() TM->>DB: ROLLBACK P-->>C: rethrow Exception end

Key Takeaway: If you call a transactional method from within the same class (e.g., this.helperMethod()), the call bypasses the proxy. This is known as the “Self-Invocation” problem. The transaction advice will not trigger, and your data may be left inconsistent.


3. Propagation Levels: Controlling Transaction Boundaries
#

Spring allows you to define how transactions relate to each other. Should a new method join an existing transaction or start a new one?

Common Propagation Strategies
#

Propagation Type Description Use Case
REQUIRED (Default) Joins active transaction if exists; creates new one if not. Most standard business logic.
REQUIRES_NEW Suspends current transaction; creates a completely new one. Audit logs, error logging (save the error even if main logic fails).
MANDATORY Throws exception if no active transaction exists. Helper methods that must not be called directly.
NESTED Creates a savepoint within the current transaction. Complex batch processing where partial failure is recoverable.

Practical Example: Banking Transfer with Audit
#

Here is a classic scenario. We want to transfer money. Regardless of whether the transfer succeeds or fails, we want to log the attempt in an audit table. This requires Propagation.REQUIRES_NEW.

package com.javadevpro.banking.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class BankingService {

    private final AccountRepository accountRepository;
    private final AuditService auditService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void transferMoney(Long fromId, Long toId, Double amount) {
        // 1. Attempt the transfer
        var fromAccount = accountRepository.findById(fromId).orElseThrow();
        var toAccount = accountRepository.findById(toId).orElseThrow();

        // 2. Log the attempt (This runs in a SEPARATE transaction)
        auditService.logTransaction("Transfer initiated from " + fromId);

        if (fromAccount.getBalance() < amount) {
            throw new RuntimeException("Insufficient funds");
        }

        fromAccount.setBalance(fromAccount.getBalance() - amount);
        toAccount.setBalance(toAccount.getBalance() + amount);
        
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
    }
}

The Audit Service:

package com.javadevpro.banking.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuditService {

    private final AuditRepository auditRepository;

    public AuditService(AuditRepository auditRepository) {
        this.auditRepository = auditRepository;
    }

    // Crucial: REQUIRES_NEW ensures this commits even if the caller rolls back
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logTransaction(String message) {
        AuditLog log = new AuditLog();
        log.setMessage(message);
        log.setTimestamp(java.time.LocalDateTime.now());
        auditRepository.save(log);
    }
}

In the code above, if “Insufficient funds” triggers a rollback in transferMoney, the audit log entry persists because it ran in an isolated transaction context.


4. Isolation Levels: Balancing Consistency and Performance
#

Isolation determines how one transaction sees the changes made by another concurrent transaction. Higher isolation means better consistency but lower performance (due to database locking).

The Four Anomalies
#

  1. Dirty Read: Reading uncommitted data from another transaction.
  2. Non-Repeatable Read: Reading the same row twice gets different data (someone updated it).
  3. Phantom Read: Running the same query twice gets a different set of rows (someone inserted/deleted).

Comparison Table
#

The following table illustrates the relationship between isolation levels and these anomalies.

Isolation Level Dirty Read Non-Repeatable Read Phantom Read Performance Cost
READ_UNCOMMITTED Allowed Allowed Allowed Lowest
READ_COMMITTED (Default for Postgres/Oracle) Prevented Allowed Allowed Moderate
REPEATABLE_READ (Default for MySQL) Prevented Prevented Allowed High
SERIALIZABLE Prevented Prevented Prevented Highest (Severe blocking)

Code Example: Avoiding Phantom Reads
#

If you are calculating a financial report, you don’t want new records appearing mid-calculation. You might escalate isolation.

@Transactional(isolation = Isolation.SERIALIZABLE)
public BigDecimal calculateDailyTotal() {
    // This locks the table or range, ensuring no new rows are inserted
    // while we sum up the values.
    return transactionRepository.sumAmountByDate(LocalDate.now());
}

Warning: Use SERIALIZABLE sparingly. It often leads to CannotAcquireLockException or deadlocks under high load.


5. Rollback Rules: The Checked Exception Trap
#

By default, Spring’s @Transactional only rolls back on Unchecked Exceptions (RuntimeException and Error). It does not roll back on Checked Exceptions (IOException, SQLException, or custom business exceptions extending Exception).

This is a frequent source of data inconsistency.

The Wrong Way:

@Transactional
public void updateUser(UserDto dto) throws UserValidationException {
    User user = repo.findById(dto.getId());
    user.setName(dto.getName());
    // If validation fails here and throws Checked Exception,
    // the name change ABOVE is still committed!
    validateUser(user); 
}

The Correct Way: Explicitly specify which exceptions trigger a rollback.

@Transactional(rollbackFor = Exception.class)
public void updateUser(UserDto dto) throws UserValidationException {
    // Logic...
}

6. Performance Optimization: Read-Only Transactions
#

For methods that only fetch data, marking the transaction as read-only provides significant performance benefits, especially with Hibernate/JPA.

@Transactional(readOnly = true)
public List<User> getActiveUsers() {
    return userRepository.findByStatus("ACTIVE");
}

Why is this faster?

  1. Dirty Checking Disabled: Hibernate usually keeps a snapshot of every object to check for changes at the end of a transaction. With readOnly = true, Hibernate skips this snapshotting, reducing memory usage and CPU cycles.
  2. Database Optimization: Some JDBC drivers (like MySQL) can send hints to the database to optimize query execution or connect to read replicas.

7. Common Pitfalls and Solutions
#

1. The @Transactional on Private Methods
#

Problem: Annotating a private method with @Transactional. Reality: Spring uses dynamic proxies (CGLIB or JDK Dynamic Proxies). Proxies cannot intercept private method calls. The annotation is ignored silently. Solution: Only use @Transactional on public methods.

2. Long-Running Transactions
#

Problem: Keeping a transaction open while performing external API calls or heavy processing. Consequence: This holds a database connection from the pool (HikariCP) for the entire duration. If the API takes 5 seconds, your DB pool will exhaust quickly. Solution: Keep transactions as short as possible. Do processing outside the transaction.

// BAD PRACTICE
@Transactional
public void processOrder(Order order) {
    saveOrder(order);
    externalPaymentGateway.charge(order); // Takes 5 seconds
    updateOrderStatus(order);
}

// GOOD PRACTICE
public void processOrder(Order order) {
    // Transaction 1: Fast
    Order saved = orderService.createOrder(order);
    
    // No DB Transaction here
    externalPaymentGateway.charge(saved); 
    
    // Transaction 2: Fast
    orderService.finalizeOrder(saved.getId());
}

Conclusion
#

Mastering @Transactional is about understanding the boundaries of your data consistency. In 2025, simply adding the annotation isn’t enough; you must understand the underlying proxy mechanism, the implications of isolation levels on concurrency, and how to manage propagation for complex workflows.

Summary Checklist:

  1. Use rollbackFor to handle checked exceptions.
  2. Use readOnly = true for fetch operations to save memory.
  3. Avoid @Transactional on private methods or self-invocations.
  4. Keep transactions short; exclude external API calls from the transactional scope.
  5. Use REQUIRES_NEW intentionally for audit logging or error tracking.

By adhering to these principles, you ensure your Java applications remain robust, performant, and reliable, even under the heavy loads of modern enterprise environments.

Further Reading: