03-data-access

Transactions: Boundaries, Propagation and the Proxy Trap

Where @Transactional belongs, what propagation and isolation change, why checked exceptions don't roll back, and the self-call that does nothing.

October 9, 2026
spring-boottransactionsTransactionalpropagationisolationjpaproxy

One Annotation, Several Surprises

java
@Transactional
public Order place(NewOrderRequest request) {
    Order order = orderRepository.save(request.toOrder());
    inventoryRepository.decrement(request.sku(), request.quantity());
    return order;
}

Both writes commit together or neither does. That is the whole point, and for the common case the annotation delivers it with no further thought.

The surprises arrive at the edges, and they are specific enough to enumerate: a checked exception that commits anyway, a method call that isn't transactional despite the annotation, a LazyInitializationException after the method returns, and a connection held for the duration of an HTTP call. All four follow from how @Transactional is implemented, so that is where to start.

It's a Proxy

Recall from phase 1 that the container owning instantiation is what lets Spring wrap your beans. @Transactional is that mechanism in action: Spring creates a proxy around your bean, and the proxy opens a transaction before your method and commits or rolls back after.

Two consequences fall straight out of that diagram, and between them they account for most confusion about this annotation.

Only calls through the proxy are intercepted. An internal call bypasses it.

Only public methods are advised by default with Spring's standard proxying — a private or protected @Transactional method is silently non-transactional.

The self-invocation trap

java
@Service
public class OrderService {
 
    public void processAll(List<NewOrderRequest> requests) {
        for (NewOrderRequest request : requests) {
            place(request);              // internal call — NOT transactional
        }
    }
 
    @Transactional
    public void place(NewOrderRequest request) { }
}

place is called on this, not on the proxy, so the annotation does nothing. No error, no warning — the code looks correct and has no transaction. This is the single most common @Transactional bug.

Three ways out:

java
// 1. Move the method to another bean — the call then goes through that bean's proxy. Preferred.
@Service
public class OrderProcessor {
    private final OrderPlacer placer;        // separate bean, separate proxy
    public void processAll(List<NewOrderRequest> requests) {
        requests.forEach(placer::place);
    }
}
 
// 2. Inject self-reference. Works, reads oddly.
@Autowired @Lazy private OrderService self;
 
// 3. Put the annotation on the outer method instead, if one transaction for the batch is right.

Option 1 is usually the better design anyway: the need for a self-call often signals that two responsibilities are sharing a class.

⚠️

Option 3 changes the semantics, not just the plumbing. One transaction for a thousand records means one failure rolls back all of them, and one very long transaction holding locks. "Each record independently" and "all records atomically" are different requirements — decide which you want rather than picking whichever makes the annotation fire.

The Rollback Rule

By default, Spring rolls back on unchecked exceptions (RuntimeException, Error) and commits on checked exceptions.

That second half catches people out, because it looks backwards:

java
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) throws InsufficientFundsException {
    debit(from, amount);
    if (balanceOf(from).signum() < 0) {
        throw new InsufficientFundsException();   // checked → COMMITS the debit
    }
    credit(to, amount);
}

The debit is committed and the credit never happens. Money disappears.

Two fixes:

java
@Transactional(rollbackFor = InsufficientFundsException.class)      // be explicit

Or — better — make domain exceptions extend RuntimeException, as the exception handling guide recommended for separate reasons. The two arguments converge: unchecked domain exceptions give you clean signatures and correct rollback.

The mirror case exists too. @Transactional(noRollbackFor = NotFoundException.class) keeps the transaction alive when an exception is control flow rather than failure.

🚨

Catching an exception inside a transactional method does not cancel a rollback that is already marked. If an inner @Transactional method threw and its transaction joined yours, the transaction is flagged rollback-only; swallowing the exception in the outer method then produces UnexpectedRollbackException at commit — "Transaction silently rolled back because it has been marked as rollback-only". The fix is Propagation.REQUIRES_NEW on the inner method if its failure genuinely shouldn't doom the outer one.

Propagation

Propagation answers: this method needs a transaction and one is already running — what now?

ValueExisting transactionNo transaction
REQUIRED (default)Join itStart one
REQUIRES_NEWSuspend it, start a new oneStart one
SUPPORTSJoin itRun without one
NOT_SUPPORTEDSuspend it, run withoutRun without one
MANDATORYJoin itThrow
NEVERThrowRun without one
NESTEDSavepoint within itStart one

REQUIRED is right almost always. Two others earn their place:

REQUIRES_NEW — for work that must survive the outer rollback. The classic case is an audit record:

java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAttempt(String action, String outcome) {
    auditRepository.save(new AuditEntry(action, outcome));
}

The outer transaction fails, the audit of the failure persists. Note the cost: a second physical connection is held while the first is suspended, so a pool sized for N concurrent requests can deadlock if every request needs two connections.

MANDATORY — to assert a caller's contract. A method that must never create its own transaction because it only makes sense as part of a larger unit. Useful on internal helpers as documentation the runtime enforces.

NESTED uses JDBC savepoints and is not supported by every provider or transaction manager; reach for it only when you specifically need partial rollback.

Check yourself

An outer @Transactional method calls an inner @Transactional (default propagation) method, which throws a RuntimeException. The outer method catches it and continues, then returns normally. What happens at commit?

Isolation

Isolation controls what concurrent transactions can see of each other. The anomalies, weakest level first:

LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible*
SERIALIZABLEPreventedPreventedPrevented
  • Dirty read — you see another transaction's uncommitted write.
  • Non-repeatable read — you read a row twice in one transaction and get different values.
  • Phantom read — you run the same query twice and the second returns extra rows.

DEFAULT means "whatever the database does", which is what you almost always want. Postgres and Oracle default to READ_COMMITTED; MySQL's InnoDB defaults to REPEATABLE_READ. Knowing which you're on matters more than changing it.

💡

The asterisk: Postgres's REPEATABLE_READ is snapshot isolation and does prevent phantom reads, unlike the standard's minimum requirement. It also means a transaction can fail at commit with a serialisation error that you must be prepared to retry — raising isolation moves the problem rather than removing it.

Raising isolation buys correctness with concurrency. SERIALIZABLE on a hot table serialises access to it. For the specific problem of two users updating the same row, optimistic locking is usually the better tool:

java
@Entity
public class Order {
    @Version
    private Long version;      // Hibernate increments and checks it
}

Hibernate adds where version = ? to each update and throws OptimisticLockingFailureException when the row changed underneath. No locks held, no reduced concurrency, and a clear failure you can surface as a 409 — which is exactly the conflict case the status-code table covers. One @Version column prevents the lost-update problem with almost no cost.

Where Boundaries Belong

In the service layer, where a business operation is defined. Not in the controller (which would hold a connection for the whole request, including serialisation) and not in the repository (where each call becomes its own transaction, making multi-write atomicity impossible).

java
@Service
public class OrderService {
 
    @Transactional                         // one business operation, one transaction
    public Order place(NewOrderRequest request) { }
 
    @Transactional(readOnly = true)        // reads
    public List<OrderSummary> list(Pageable pageable) { }
}

Mark reads readOnly = true. It lets Hibernate skip dirty checking and flushing, and tells the driver (and any read-replica routing) that no writes are coming. It is nearly free and measurably faster on large result sets.

Keep transactions short

A transaction holds a database connection and any locks it has taken. So:

java
@Transactional
public void placeAndNotify(NewOrderRequest request) {
    Order order = orderRepository.save(request.toOrder());
    paymentGateway.charge(order);        // ← HTTP call inside a transaction
    emailClient.sendConfirmation(order); // ← and another
}

This holds a connection for the duration of two network calls. Under load the pool empties and every request queues — the same cascading-failure shape as the timeout discussion, with the connection pool as the exhausted resource instead of the thread pool.

Keep I/O outside the transaction:

java
public void placeAndNotify(NewOrderRequest request) {
    Order order = orderService.place(request);     // transactional, short
    paymentGateway.charge(order);                  // outside
    emailClient.sendConfirmation(order);           // outside
}

Now you have a different problem — the order is committed but the charge may fail — and that is a genuine distributed-systems question (outbox pattern, compensating actions, idempotent retries) rather than something to paper over by extending the transaction. Phase 7 and the system design track deal with it properly.

✅

@Transactional on a @SpringBootTest method rolls back after each test, which keeps tests isolated without manual cleanup. Be aware it also hides transaction-boundary bugs: code that only works because the test's transaction is still open — lazy loading, for instance — will fail in production. Use @Transactional(propagation = NOT_SUPPORTED) or commit deliberately when you specifically want to test boundary behaviour.

LazyInitializationException, Explained

The most-reported JPA error, and now explicable in one sentence: a lazy association can only be loaded while the persistence context is open, and the persistence context closes when the transaction ends.

java
@Transactional(readOnly = true)
public Order find(Long id) {
    return orderRepository.findById(id).orElseThrow();
}
// transaction ends here — order is now detached
 
// in the controller:
order.getCustomer().getName();     // LazyInitializationException

Spring Boot's default of spring.jpa.open-in-view: true papers over this by keeping the persistence context open for the whole request. It logs a warning at startup, and the warning is right: it means queries fire during view rendering, outside any transaction, invisible to the service layer, with the connection held until the response completes.

Turn it off and fix the cause:

yaml
spring:
  jpa:
    open-in-view: false

Then load what you need inside the transaction — a fetch join, an entity graph, or a projection — and return a DTO. The entities-are-not-response-bodies rule from the Jackson guide resolves this problem and several others at once.

Check yourself

A service method is annotated @Transactional and calls an external payment API that occasionally takes 30 seconds. Under load the app fails with connection-pool timeouts on endpoints that don't touch payments. Why?

The Mental Model, Restated

  1. @Transactional is a proxy. Internal self-calls and non-public methods are not intercepted.
  2. Unchecked exceptions roll back; checked ones commit. Make domain exceptions extend RuntimeException.
  3. A caught exception doesn't un-mark a rollback — hence UnexpectedRollbackException.
  4. REQUIRED is almost always right. REQUIRES_NEW for audit-style work that must survive a rollback.
  5. Leave isolation at the database default and use @Version optimistic locking for lost updates.
  6. Boundaries go in the service layer, reads marked readOnly.
  7. No network I/O inside a transaction. It pins a connection and starves the pool.
  8. Turn off open-in-view and return DTOs loaded inside the transaction.

What's Next

JPA is the right default for entity-shaped work, and the wrong tool for a reporting query across six tables or a bulk update. The next guide covers Spring's JDBC support — JdbcClient for fluent SQL, RowMapper for result mapping, named parameters — and when dropping the ORM is the simpler choice rather than a regression.