Spring @Transactional: Propagation and Isolation

Introduction

This tutorial explains Spring's @Transactional annotation through a bank transfer that must either happen completely or not at all. We will see what a transaction is and how Spring adds one around a method with a proxy. We will then run nine cases and print the balances after each: commit, rollback on a runtime exception, the surprising commit on a checked exception and how rollbackFor changes it, and the self-call that silently loses the transaction. Next come the propagation settings REQUIRED, REQUIRES_NEW and MANDATORY, the UnexpectedRollbackException, and readOnly. Lastly, we will watch two threads to see what an isolation level means in practice.

What a transaction is, and what @Transactional does

A transaction groups several database changes into one unit: either all of them are committed, or none are. Moving 30 from Alice to Bob is two updates. If the program fails between them, the money must not vanish from one account without arriving in the other.

In plain JDBC you would call setAutoCommit(false), then commit() or rollback(), in every method. Spring moves that into an annotation. When a bean has @Transactional methods, Spring gives other beans a proxy in its place, the same mechanism as AOP in module 1. A call through the proxy runs like this -
  • The proxy asks the transaction manager to start a transaction, or to join one that is already running. With Spring Data JPA, Boot configures a JpaTransactionManager.
  • It calls the real method.
  • If the method returns normally, the proxy commits. If it throws, the proxy decides from the exception type whether to roll back, as we will see.
Inside the transaction, JPA's dirty checking is active: entities loaded in it are managed, and any change to them is written to the database at commit, without a save() call.

The example

Two entities, an Account and an AuditEntry, each with a repository. An AuditService writes audit rows with different propagation settings, and a TransferService moves money in several ways. The application runs each case and prints the committed balances after it.
Account.java - an account with a balance
//Account.java
package com.javabrahman.data.tx;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Account {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String owner;
  private long balance;

  protected Account() {
  }

  public Account(String owner, long balance) {
    this.owner = owner;
    this.balance = balance;
  }

  public Long getId() { return id; }
  public String getOwner() { return owner; }
  public long getBalance() { return balance; }
  public void setBalance(long balance) { this.balance = balance; }
}
AuditEntry.java - one line of an audit log
//AuditEntry.java
package com.javabrahman.data.tx;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class AuditEntry {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String message;

  protected AuditEntry() {
  }

  public AuditEntry(String message) {
    this.message = message;
  }

  public String getMessage() { return message; }
}
AccountRepository.java
//AccountRepository.java
package com.javabrahman.data.tx;

import org.springframework.data.jpa.repository.JpaRepository;

public interface AccountRepository extends JpaRepository<Account, Long> {
  Account findByOwner(String owner);
}
AuditRepository.java
//AuditRepository.java
package com.javabrahman.data.tx;

import org.springframework.data.jpa.repository.JpaRepository;

public interface AuditRepository extends JpaRepository<AuditEntry, Long> {
}
AuditService.java - the same work with different propagation
//AuditService.java
package com.javabrahman.data.tx;

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

@Service
public class AuditService {
  private final AuditRepository audit;

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

  @Transactional   // propagation = REQUIRED, the default: join the caller's transaction
  public void logJoined(String message) {
    audit.save(new AuditEntry(message));
  }

  @Transactional(propagation = Propagation.REQUIRES_NEW)   // suspend the caller's, run in its own
  public void logInOwnTransaction(String message) {
    audit.save(new AuditEntry(message));
  }

  @Transactional(propagation = Propagation.MANDATORY)   // a caller's transaction must exist
  public void logMandatory(String message) {
    audit.save(new AuditEntry(message));
  }

  @Transactional   // joins the caller's transaction and fails inside it
  public void logAndFail(String message) {
    audit.save(new AuditEntry(message));
    throw new IllegalStateException("audit failed");
  }
}
TransferService.java - transfers that commit, roll back, or do not
//TransferService.java
package com.javabrahman.data.tx;

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

@Service
public class TransferService {
  private final AccountRepository accounts;
  private final AuditService auditService;

  public TransferService(AccountRepository accounts, AuditService auditService) {
    this.accounts = accounts;
    this.auditService = auditService;
  }

  @Transactional
  public void transfer(String from, String to, long amount) {
    Account source = accounts.findByOwner(from);
    Account target = accounts.findByOwner(to);
    source.setBalance(source.getBalance() - amount);   // no save() needed: dirty checking
    target.setBalance(target.getBalance() + amount);
    if (source.getBalance() < 0) {
      throw new IllegalStateException("insufficient funds in " + from + "'s account");
    }
  }

  @Transactional
  public void transferThenCheckedException(String from, String to, long amount) throws Exception {
    transfer(from, to, amount);   // a plain method call here: no new transaction
    throw new Exception("checked exception after the transfer");
  }

  @Transactional(rollbackFor = Exception.class)
  public void transferThenCheckedExceptionRollback(String from, String to, long amount) throws Exception {
    transfer(from, to, amount);
    throw new Exception("checked exception after the transfer");
  }

  // NOT transactional, and calls transfer() on "this" - the proxy is bypassed
  public void transferViaSelfCall(String from, String to, long amount) {
    transfer(from, to, amount);
  }

  @Transactional
  public void transferWithAudit(String from, String to, long amount) {
    auditService.logInOwnTransaction("attempt: " + from + " to " + to + " " + amount);
    auditService.logJoined("joined: " + from + " to " + to + " " + amount);
    transfer(from, to, amount);
  }

  @Transactional
  public void catchInnerFailure() {
    try {
      auditService.logAndFail("inner work");
    } catch (IllegalStateException e) {
      System.out.println("   caught inside the outer method: " + e.getMessage());
    }
  }

  @Transactional(readOnly = true)
  public void changeInReadOnly(String owner) {
    accounts.findByOwner(owner).setBalance(1_000_000);
  }
}
TxApp.java - running each case and printing what was committed
//TxApp.java
package com.javabrahman.data.tx;

import java.util.concurrent.CountDownLatch;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

@SpringBootApplication
public class TxApp {

  @Bean
  CommandLineRunner demo(TransferService transfers, AuditService auditService, AccountRepository accounts,
                         AuditRepository audit, PlatformTransactionManager txManager) {
    return args -> {
      accounts.save(new Account("alice", 100));
      accounts.save(new Account("bob", 100));
      System.out.println("transaction manager: " + txManager.getClass().getSimpleName());
      balances(accounts, "start");

      run("1. transfer 30, no exception", () -> transfers.transfer("alice", "bob", 30));
      balances(accounts, "after 1");

      run("2. transfer 500, runtime exception", () -> transfers.transfer("alice", "bob", 500));
      balances(accounts, "after 2");

      run("3. transfer 10, then checked exception", () -> transfers.transferThenCheckedException("alice", "bob", 10));
      balances(accounts, "after 3");

      run("4. transfer 10, checked exception, rollbackFor", () -> transfers.transferThenCheckedExceptionRollback("alice", "bob", 10));
      balances(accounts, "after 4");

      run("5. transfer 20 through a self-call", () -> transfers.transferViaSelfCall("alice", "bob", 20));
      balances(accounts, "after 5");

      run("6. transfer 500 with audit (REQUIRES_NEW and REQUIRED)", () -> transfers.transferWithAudit("alice", "bob", 500));
      balances(accounts, "after 6");
      audit.findAll().forEach(e -> System.out.println("   audit row: " + e.getMessage()));

      run("7. MANDATORY without a transaction", () -> auditService.logMandatory("no caller transaction"));

      run("8. inner REQUIRED method fails, outer catches", transfers::catchInnerFailure);

      run("9. change inside readOnly = true", () -> transfers.changeInReadOnly("alice"));
      balances(accounts, "after 9");

      isolation(txManager, accounts, TransactionDefinition.ISOLATION_READ_COMMITTED);
      isolation(txManager, accounts, TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
    };
  }

  // Writer changes alice's balance and waits before rolling back; reader reads it meanwhile
  private static void isolation(PlatformTransactionManager txManager, AccountRepository accounts, int level)
      throws InterruptedException {
    String name = level == TransactionDefinition.ISOLATION_READ_COMMITTED ? "READ_COMMITTED" : "READ_UNCOMMITTED";
    System.out.println("== isolation " + name);
    CountDownLatch written = new CountDownLatch(1);
    CountDownLatch read = new CountDownLatch(1);

    Thread writer = new Thread(() -> new TransactionTemplate(txManager).executeWithoutResult(status -> {
      Account alice = accounts.findByOwner("alice");
      alice.setBalance(999);
      accounts.flush();            // send the update to the database, not yet committed
      written.countDown();
      await(read);
      status.setRollbackOnly();    // and then roll it back
    }));

    TransactionTemplate readerTx = new TransactionTemplate(txManager);
    readerTx.setIsolationLevel(level);
    Thread reader = new Thread(() -> readerTx.executeWithoutResult(status -> {
      await(written);
      System.out.println("   reader sees alice's balance: " + accounts.findByOwner("alice").getBalance());
      read.countDown();
    }));

    writer.start();
    reader.start();
    writer.join();
    reader.join();
    balances(accounts, "after the writer rolled back");
  }

  private static void await(CountDownLatch latch) {
    try {
      latch.await();
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }
  }

  private static void run(String label, ThrowingRunnable work) {
    System.out.println("== " + label);
    try {
      work.run();
      System.out.println("   completed normally");
    } catch (Exception e) {
      System.out.println("   " + e.getClass().getSimpleName() + ": " + e.getMessage());
    }
  }

  private static void balances(AccountRepository accounts, String when) {
    System.out.println("   " + when + ": alice " + accounts.findByOwner("alice").getBalance()
        + ", bob " + accounts.findByOwner("bob").getBalance());
  }

  interface ThrowingRunnable {
    void run() throws Exception;
  }

  public static void main(String[] args) {
    SpringApplication.run(TxApp.class, args);
  }
}
 OUTPUT of the above code
transaction manager: JpaTransactionManager
   start: alice 100, bob 100
== 1. transfer 30, no exception
   completed normally
   after 1: alice 70, bob 130
== 2. transfer 500, runtime exception
   IllegalStateException: insufficient funds in alice's account
   after 2: alice 70, bob 130
== 3. transfer 10, then checked exception
   Exception: checked exception after the transfer
   after 3: alice 60, bob 140
== 4. transfer 10, checked exception, rollbackFor
   Exception: checked exception after the transfer
   after 4: alice 60, bob 140
== 5. transfer 20 through a self-call
   completed normally
   after 5: alice 60, bob 140
== 6. transfer 500 with audit (REQUIRES_NEW and REQUIRED)
   IllegalStateException: insufficient funds in alice's account
   after 6: alice 60, bob 140
   audit row: attempt: alice to bob 500
== 7. MANDATORY without a transaction
   IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'
== 8. inner REQUIRED method fails, outer catches
   caught inside the outer method: audit failed
   UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
== 9. change inside readOnly = true
   completed normally
   after 9: alice 60, bob 140
== isolation READ_COMMITTED
   reader sees alice's balance: 60
   after the writer rolled back: alice 60, bob 140
== isolation READ_UNCOMMITTED
   reader sees alice's balance: 999
   after the writer rolled back: alice 60, bob 140

Commit and rollback (cases 1 to 4)

  • Case 1 commits: Alice 70, Bob 130. transfer() never calls save(). The two accounts were loaded inside the transaction, so dirty checking wrote both changes at commit.
  • Case 2 throws an IllegalStateException after both balances were changed in memory. The proxy rolled back, and the balances stayed at 70 and 130: neither update reached the database.
  • Case 3 is the one that surprises people. The transfer of 10 is followed by a checked exception, and the balances became 60 and 140. By default, Spring rolls back only on unchecked exceptions (RuntimeException and Error). A checked exception is treated as a business outcome, and the transaction commits. The caller still receives the exception.
  • Case 4 is the same code with @Transactional(rollbackFor = Exception.class). Now the checked exception rolls back, and the balances stay at 60 and 140. If your methods throw checked exceptions for failures, declare rollbackFor, or throw unchecked exceptions instead.

The self-call trap (case 5)

transferViaSelfCall() has no annotation, and calls the annotated transfer() on itself. The call goes to this, the real object, not through the proxy, so @Transactional is never seen. The output shows the result: "completed normally", yet the balances are still 60 and 140. Without a transaction, each repository call ran in its own short transaction, the loaded accounts were detached at once, and the changes to them were never written. Nothing failed, and the transfer was lost.

The same happens with a call to a private method, since the proxy cannot intercept it. The fixes are to put the annotation on the method that is called from outside, which is usually the right design, or to move the transactional method into another bean, as AuditService is here.

Propagation: what happens when a transaction is already running

Propagation decides what a @Transactional method does when it is called while a transaction is already active -
  • REQUIRED (the default): join the running transaction, or start one if there is none.
  • REQUIRES_NEW: suspend the running transaction and start a separate one, which commits or rolls back on its own.
  • MANDATORY: join the running transaction, and fail if there is none.
  • SUPPORTS: join if there is one, else run without; NOT_SUPPORTED: suspend any running transaction; NEVER: fail if one is running; NESTED: a savepoint inside the running transaction, for transaction managers that support it.
  • Case 6: transferWithAudit() writes two audit rows and then fails with insufficient funds. Only one audit row survived: "attempt: alice to bob 500", written with REQUIRES_NEW, had committed in its own transaction before the failure. The "joined" row was part of the outer transaction and was rolled back with the transfer. This is the usual use of REQUIRES_NEW: an audit or error log that must be kept even when the business operation fails.
  • Case 7: logMandatory() called without a running transaction fails with IllegalTransactionStateException, "No existing transaction found for transaction marked with propagation 'mandatory'". Use it for methods that must only run as part of a larger unit of work.
  • Case 8: the outer method calls logAndFail(), which joins with REQUIRED and throws. The outer method catches the exception and returns normally, yet the caller gets UnexpectedRollbackException: "Transaction silently rolled back because it has been marked as rollback-only". The inner method shares the outer transaction. When the exception passed through the inner proxy, the proxy marked the shared transaction rollback-only, and catching the exception afterwards cannot undo that. If an inner failure is meant to be tolerated, the inner work needs its own transaction (REQUIRES_NEW), or must not throw through a transactional proxy.

readOnly (case 9)

changeInReadOnly() sets Alice's balance to 1,000,000 inside a @Transactional(readOnly = true) method. It completed normally, and the balance is still 60. With JPA, a read-only transaction tells Hibernate not to flush, and to skip dirty checking for the loaded entities, so the change was never written. That also makes read-only transactions cheaper, which is why it is good practice to mark query-only service methods with readOnly = true. Spring Data's own repository read methods already run that way. Do not rely on it to block writes, though: an explicit update query can still run, depending on the database and driver.

Isolation: what one transaction sees of another

Isolation decides how much one running transaction sees of another one's uncommitted changes. The SQL standard defines four levels, each preventing more anomalies than the one before -
  • READ_UNCOMMITTED: may see other transactions' uncommitted changes, called dirty reads.
  • READ_COMMITTED: sees only committed data, but reading the same row twice may give different values (non-repeatable reads).
  • REPEATABLE_READ: a row read once reads the same again, but a repeated query may find new rows (phantom reads).
  • SERIALIZABLE: transactions behave as if they ran one after another. It is the safest and the slowest level.
@Transactional(isolation = Isolation.READ_COMMITTED) sets it on a method; the program uses a TransactionTemplate to set it in code. The default, Isolation.DEFAULT, uses the database's own default, which for H2, PostgreSQL, Oracle and SQL Server is READ_COMMITTED, and for MySQL's InnoDB is REPEATABLE_READ.

In the last two blocks of the output, a writer thread sets Alice's balance to 999, flushes the update to the database without committing, and waits. A reader thread then reads the balance, and the writer rolls back -
  • With READ_COMMITTED, the reader saw 60, the committed value.
  • With READ_UNCOMMITTED, the reader saw 999, a value that was rolled back a moment later and never existed in committed data. That is a dirty read. Code that made a decision on it would have acted on money that was never there.
Higher isolation means more locking or more failed transactions under contention, so most applications keep the default and handle the remaining cases with locking, the topic of optimistic and pessimistic lockingClick to read tutorial: Optimistic and Pessimistic Locking in Spring Data JPA.

Where to put @Transactional

  • On service methods that form one business operation, not on controllers, and not only on repositories, whose own methods are already transactional one call at a time.
  • On public methods called from other beans, because of the proxy.
  • With readOnly = true on query methods, and rollbackFor where checked exceptions mean failure.
  • Keep transactions short: no remote calls or user waits inside them, since they hold database connections and locks for their whole duration.

Summary

In this tutorial we saw how @Transactional wraps a method in a transaction through a proxy, and how dirty checking writes changes to managed entities at commit. Running nine cases, we saw a commit, a rollback on a runtime exception, a commit despite a checked exception and its fix with rollbackFor, and a self-call that lost the transaction. We then compared the propagation settings REQUIRES_NEW, REQUIRED and MANDATORY, explained the UnexpectedRollbackException and readOnly, and watched a dirty read happen under READ_UNCOMMITTED. The next tutorial is about reading only the data a use case needs: Spring Data JPA projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs.