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.
save() call.
The example
Two entities, anAccount 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);
}
}
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 callssave(). The two accounts were loaded inside the transaction, so dirty checking wrote both changes at commit. - Case 2 throws an
IllegalStateExceptionafter 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 (
RuntimeExceptionandError). 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, declarerollbackFor, 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 withREQUIRES_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 ofREQUIRES_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 withIllegalTransactionStateException, "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 withREQUIREDand throws. The outer method catches the exception and returns normally, yet the caller getsUnexpectedRollbackException: "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.
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 = trueon query methods, androllbackForwhere 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.Spring Data Tutorials
JPA Lazy Loading and the N+1 Problem in Spring BootClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained Spring Data JPA @Query: JPQL and Native QueriesClick to read tutorial: Spring Data JPA @Query: JPQL and Native Queries JPA Entity Relationships: OneToOne, OneToMany, ManyToManyClick to read tutorial: JPA Entity Relationships: OneToOne, OneToMany, ManyToMany Spring Data JPA Auditing: @CreatedDate and @CreatedByClick to read tutorial: Spring Data JPA Auditing: @CreatedDate and @CreatedBy
JPA Lazy Loading and the N+1 Problem in Spring BootClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs Spring Data JPA Introduction: Entities and RepositoriesClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained Spring Data JPA @Query: JPQL and Native QueriesClick to read tutorial: Spring Data JPA @Query: JPQL and Native Queries JPA Entity Relationships: OneToOne, OneToMany, ManyToManyClick to read tutorial: JPA Entity Relationships: OneToOne, OneToMany, ManyToMany Spring Data JPA Auditing: @CreatedDate and @CreatedByClick to read tutorial: Spring Data JPA Auditing: @CreatedDate and @CreatedBy