Transactions in Spring

@Transactional's proxy, propagation types, isolation, read-only, rollback rules, and the checked exception that does not roll back.

7 min read🗃️ Spring Data JPA and Hibernate

@Transactional is one word that does a great deal, and the reason it surprises people is that the word is not where the work happens. Spring does not rewrite your method. It wraps your bean in a proxy, and the proxy is what starts and ends the transaction.

Every trap in this lesson is a consequence of that one sentence.

The proxy, and the call that never reaches it

When a bean is annotated, the object Spring hands to everyone else is not your object. It is a generated subclass (or a JDK interface proxy) that looks identical, and whose job is: begin a transaction, call the real method, commit or roll back.

another beanproxy interceptsyour methodit returnsthis.debit(id)
who is callinga different beangoes through the proxyyestransactionnone yet

another bean. A controller or another service holds a reference to what it thinks is your service. It is holding the proxy — Spring injected that, not your instance.

1 / 5

Self-invocation, measured

That last frame is the trap, and it is worth seeing rather than being warned about. Two calls to the same annotated method, differing only in who calls it:

Money.javajava
@Service
public class Money {
    private final Accounts accounts;
 
    /** No annotation here. It calls the annotated method on `this`. */
    public void selfInvoked(Long id) {
        this.debit(id);
    }
 
    @Transactional
    public void debit(Long id) {
        Account a = accounts.findById(id).get();
        a.balance -= 10;          // dirty checking is supposed to write this
    }
}
plaintext
### A: calling debit() from INSIDE the bean (this.debit)
###   inside debit(): transaction active? false
### balance for ana: 100  (was 100)
 
### B: calling debit() from OUTSIDE the bean
###   inside debit(): transaction active? true
### balance for bo:  90  (was 100)

Same method. Same annotation. In the first case there is no transaction, so there is no persistence context to hold a managed entity, so dirty checking never happens and the debit is silently discarded. No exception. No warning. A log line would show the select and no update, and only if anyone were looking.

Three ways out, in the order you should prefer them:

  1. Move the method to another bean. Usually the right answer, because a method that needs its own transaction is usually a different responsibility.
  2. Inject the proxy into itself@Lazy on a self-reference, then self.debit(id). It works and it looks strange, which is honest: it looks strange because something is.
  3. AopContext.currentProxy(). Works, requires exposeProxy = true, and ties your code to Spring's AOP. Last resort.

Rollback rules are not what you expect

Ask most engineers what happens when a @Transactional method throws, and they will say it rolls back. That is true for unchecked exceptions and false for checked ones:

plaintext
### C: debit, then throw a CHECKED exception
###   caught Checked
### balance for cy:  90  (was 100)
 
### D: debit, then throw an UNCHECKED exception
###   caught Unchecked
### balance for di:  100  (was 100)

Read C again. The method debited the account, threw an exception, the caller caught it — and the money moved anyway. The transaction committed on the way out because a checked exception is, by Spring's default, a business outcome rather than a failure.

That default comes from EJB and is defensible in theory: a checked exception is a declared, expected result. In practice, teams write checked exceptions for exactly the cases where they mean stop, undo this. So say it:

java
@Transactional(rollbackFor = Checked.class)
plaintext
### E: checked again, but with rollbackFor = Checked.class
###   caught Checked
### balance for ed:  100  (was 100)

Propagation: what happens when a transaction is already running

Propagation answers one question: this method wants a transaction, and the caller already has one — now what?

PropagationWith a transaction already running
REQUIRED (default)join it. One transaction, one commit, all-or-nothing together
REQUIRES_NEWsuspend the caller's, start a second, independent one
NESTEDa savepoint inside the caller's; can roll back alone
SUPPORTSjoin if there is one, otherwise run with none
MANDATORYthrow if there is not one
NEVERthrow if there is one

The two that matter are the first two, and the distinction is worth stating as a rule: REQUIRED means your work dies with the caller's; REQUIRES_NEW means it survives.

That is what makes REQUIRES_NEW the right choice for an audit record or a failed-attempt log — the thing you most want written is the record of the operation that just failed, and under REQUIRED it rolls back along with everything else.

It also carries a real cost that is easy to miss: REQUIRES_NEW takes a second connection from the pool while the first is still held open and suspended. A request that nests three of those holds four connections. On a pool of ten, that is a deadlock waiting for traffic.

Isolation, briefly and honestly

Isolation decides what one transaction can see of another's uncommitted or in-flight work:

LevelPrevents
READ_UNCOMMITTEDnothing; you can read another transaction's uncommitted rows
READ_COMMITTEDdirty reads
REPEATABLE_READdirty reads and non-repeatable reads
SERIALIZABLEall of it, including phantoms

The honest advice is: leave it alone. PostgreSQL defaults to READ_COMMITTED, MySQL's InnoDB to REPEATABLE_READ, and both are right for almost all application work. Raising it trades throughput for a guarantee you usually do not need, and raising it for one method is an invitation to a lock ordering problem that appears under load and nowhere else.

Reach for the locking below instead. It is more precise and it fails in a way you can catch.

Optimistic locking is a column, not a lock

Add one field:

java
@Entity
public class Account {
    @Id public Long id;
    public int balance;
    @Version public Long version;
}

Hibernate now includes the version in every update's where clause and increments it:

plaintext
### F: @Version on an ordinary update
###   version on load: 0, balance 100
### version afterwards: 1

Nothing is locked. Nobody waits. The check happens at write time: if the row's version is no longer the one you read, your update matches zero rows and Hibernate knows somebody changed it underneath you.

plaintext
### G: two writers, one stale read
###   user A read version 0
###   user B has written and bumped the version
###   user A now commits...
###   org.springframework.orm.ObjectOptimisticLockingFailureException

In a real system B's transaction has committed and B's change stands; A's is rejected and A must retry with fresh data. That is the whole trade: cheap when conflicts are rare, and it turns a silent lost update into a loud exception.

Without @Version, the same sequence produces no error at all. A read the change, B read the same row, both wrote, and whoever wrote last silently erased the other. Nobody finds out until the numbers stop adding up.

Pessimistic locking is the other answer: SELECT ... FOR UPDATE, via @Lock(LockModeType.PESSIMISTIC_WRITE). It holds a database lock for the length of the transaction, so the second reader waits. Correct, and expensive, and a source of deadlocks if two transactions take locks in different orders. Use it when conflicts are the normal case — seat booking, stock decrement on a hot item — and optimistic everywhere else.

Progress is saved on this device and to your account when signed in.