DevNotes
Support
Spring Data JPA & Hibernate · Chapter 11

Transactions & Locking

How @Transactional works through proxies, where to put it, readOnly, rollback rules for checked exceptions, propagation (REQUIRED, REQUIRES_NEW, NESTED…), isolation levels, the self-invocation trap, optimistic locking with @Version and pessimistic locks with @Lock.

◷ 8 min read§ 14 sections? 7 interview questions{ } 12 code examples

#Introduction

Every write in a JPA application runs inside a transaction — and most subtle data bugs (lost updates, partial writes, "my rollback didn't happen") come from misunderstanding how Spring's @Transactional works.

#How @Transactional Works

Java
@Service
public class TransferService {

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accounts.findById(fromId).orElseThrow();
        Account to = accounts.findById(toId).orElseThrow();
        from.withdraw(amount);
        to.deposit(amount);
    }   // commit → both UPDATEs, or neither
}

Spring wraps the bean in a proxy. Calls go through it:

Caller calls transferService.transfer()
Proxy: begin transaction — get a connection, open the persistence context
Your method runs
Did it throw a RuntimeException or Error?
Rollback
Flush and commit
Proxy releases the connection

#Where to Put It

  • On service methods — a transaction should cover one business operation (several repository calls).
  • Repository methods are already transactional: SimpleJpaRepository reads use readOnly = true, writes use @Transactional.
  • Not on controllers — keep transactions short and away from HTTP/view concerns.
  • It works on public methods called through the proxy (Spring 6 also supports protected and package-private methods with class-based proxies, but keep it public).

#readOnly = true

Java
@Transactional(readOnly = true)
public List<EmployeeDto> list() { … }
  • Hibernate sets the session to read-only: no snapshots, no dirty checking, no flush — less memory and CPU.
  • Spring can route to a read replica (with a routing DataSource) and the JDBC driver may optimise.
  • It's a hint, not a security mechanism — but use it for all query-only service methods.

#Rollback Rules

Java
@Transactional(rollbackFor = Exception.class)          // roll back on checked exceptions too
public void importFile(Path file) throws IOException { … }

@Transactional(noRollbackFor = EmailFailedException.class)
public void register(User u) { … }

Catching an exception inside the method and not rethrowing it means no rollback — the proxy never sees it. And if an inner transactional call marked the transaction rollback-only, the outer commit fails with UnexpectedRollbackException.

#Propagation

What happens when a transactional method calls another?

PropagationBehaviour
REQUIRED (default)Join the current transaction; create one if none
REQUIRES_NEWSuspend the current transaction and start an independent one
NESTEDA savepoint inside the current transaction — partial rollback. Works with DataSourceTransactionManager; JpaTransactionManager disables it by default because a savepoint can't roll back the persistence context
SUPPORTSJoin if one exists, otherwise run without
MANDATORYMust be called inside a transaction, else exception
NOT_SUPPORTEDSuspend any transaction; run without
NEVERException if a transaction exists
Java
@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String event) { auditRepository.save(new AuditLog(event)); }
}

@Transactional
public void placeOrder(Order o) {
    orders.save(o);
    audit.record("ORDER_PLACED");   // committed even if placeOrder later rolls back
    payment.charge(o);              // throws → order rolled back, audit row kept
}

#The Self-Invocation Trap

Java
@Service
public class ReportService {
    public void generateAll() {
        for (Long id : ids) generate(id);   // ❌ this.generate() — bypasses the proxy
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void generate(Long id) { … }      // no new transaction!
}

Calls within the same class don't go through the proxy, so @Transactional (and @Async, @Cacheable) on the called method is ignored. Fixes: move the method to another bean, inject the bean's own proxy (@Lazy self-injection), or use TransactionTemplate programmatically.

Java
transactionTemplate.executeWithoutResult(status -> generateOne(id));

#Isolation Levels

LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTED (PostgreSQL, Oracle, SQL Server default)❌PossiblePossible
REPEATABLE_READ (MySQL InnoDB default)❌❌Possible in the standard (InnoDB mostly prevents them)
SERIALIZABLE❌❌❌
Java
@Transactional(isolation = Isolation.SERIALIZABLE)

Isolation.DEFAULT uses the database's level. Higher isolation = more locking or more serialization failures to retry. Most applications keep the default and use locking for the specific rows that need it.

#The Lost Update Problem

Text
T1: read stock = 10        T2: read stock = 10
T1: stock = 10 - 3 = 7     T2: stock = 10 - 5 = 5
T1: commit (7)             T2: commit (5)   ← T1's update is lost; should be 2

Two solutions: optimistic or pessimistic locking.

#Optimistic Locking — @Version

Java
@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private int stock;

    @Version
    private Long version;
}
SQL
UPDATE product SET stock = ?, version = 8 WHERE id = ? AND version = 7

If another transaction already changed the row, the version doesn't match, 0 rows update, and Hibernate throws OptimisticLockException (Spring: ObjectOptimisticLockingFailureException). Catch it and retry or tell the user the data changed.

  • No database locks are held — great for low-contention data (most business entities).
  • Also protects across requests: send the version to the client and back, so a stale form submission fails.
  • ⚠️ JPQL/native bulk updates don't increment the version unless you do it yourself.
Java
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3)
@Transactional
public void reserve(Long productId, int qty) { … }

(@Retryable comes from Spring Retry — and must wrap the transaction, not run inside it.)

#Pessimistic Locking — @Lock

Java
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findForUpdate(@Param("id") Long id);
}
SQL
SELECT … FROM product WHERE id = ? FOR UPDATE

The row stays locked until the transaction ends; other writers (and FOR UPDATE readers) wait.

Optimistic (@Version)Pessimistic (@Lock)
MechanismVersion check at update timeDatabase row lock (SELECT … FOR UPDATE)
ConflictsDetected → exception → retryPrevented → others wait
Best forLow contention, long user think timeHigh contention, short critical sections (stock, seats, balances)
RisksRetries under heavy contentionWaiting, deadlocks, timeouts

PESSIMISTIC_READ takes a shared lock (FOR SHARE); PESSIMISTIC_WRITE an exclusive one. Always lock rows in a consistent order to avoid deadlocks, and keep locked transactions short.

#Keep Transactions Short

  • Don't call slow external APIs, send emails or wait for user input inside a transaction — it holds a connection and locks.
  • Do the remote call first (or after commit — @TransactionalEventListener(phase = AFTER_COMMIT)), and keep the database part tight.

#Interview Questions

Q1How does @Transactional work?

Spring creates a proxy around the bean. On a call, the proxy starts (or joins) a transaction, invokes the method, then commits — or rolls back on a runtime exception — and releases the connection.

Q2Which exceptions cause a rollback?

By default unchecked exceptions (RuntimeException) and Error. Checked exceptions commit unless you configure rollbackFor.

Q3Why doesn't @Transactional work when calling a method in the same class?

Self-invocation bypasses the Spring proxy, so the annotation on the called method isn't applied. Move it to another bean, self-inject, or use TransactionTemplate.

Q4REQUIRED vs REQUIRES_NEW?

REQUIRED joins the existing transaction (one commit or rollback for everything). REQUIRES_NEW suspends it and runs an independent transaction that commits or rolls back on its own.

Q5What does readOnly = true do?

It hints that no writes will occur: Hibernate skips dirty checking and flushing, and the driver or routing data source can optimise. Use it for query-only methods.

Q6Optimistic vs pessimistic locking?

Optimistic uses a @Version column and fails the update if the row changed since it was read (then you retry). Pessimistic locks the row with SELECT … FOR UPDATE so others wait. Optimistic for low contention; pessimistic for hot rows.

Q7How does @Version work?

Hibernate adds WHERE version = ? to updates and increments it. If no row matches, someone else changed it, and an OptimisticLockException is thrown.

#Key Takeaways

  • @Transactional works through proxies — public methods, called from another bean.
  • Put it on service methods; use readOnly = true for reads.
  • Checked exceptions don't roll back by default — use rollbackFor.
  • REQUIRED joins, REQUIRES_NEW runs independently (and uses another connection).
  • Prevent lost updates with @Version (optimistic) or @Lock(PESSIMISTIC_WRITE).
  • Keep transactions short — no remote calls inside.