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.
One Annotation, Several Surprises
@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
@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:
// 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:
@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:
@Transactional(rollbackFor = InsufficientFundsException.class) // be explicitOr — 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?
| Value | Existing transaction | No transaction |
|---|---|---|
REQUIRED (default) | Join it | Start one |
REQUIRES_NEW | Suspend it, start a new one | Start one |
SUPPORTS | Join it | Run without one |
NOT_SUPPORTED | Suspend it, run without | Run without one |
MANDATORY | Join it | Throw |
NEVER | Throw | Run without one |
NESTED | Savepoint within it | Start 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:
@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:
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ_UNCOMMITTED | Possible | Possible | Possible |
READ_COMMITTED | Prevented | Possible | Possible |
REPEATABLE_READ | Prevented | Prevented | Possible* |
SERIALIZABLE | Prevented | Prevented | Prevented |
- 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:
@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).
@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:
@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:
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.
@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(); // LazyInitializationExceptionSpring 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:
spring:
jpa:
open-in-view: falseThen 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
@Transactionalis a proxy. Internal self-calls and non-public methods are not intercepted.- Unchecked exceptions roll back; checked ones commit. Make domain exceptions extend
RuntimeException. - A caught exception doesn't un-mark a rollback — hence
UnexpectedRollbackException. REQUIREDis almost always right.REQUIRES_NEWfor audit-style work that must survive a rollback.- Leave isolation at the database default and use
@Versionoptimistic locking for lost updates. - Boundaries go in the service layer, reads marked
readOnly. - No network I/O inside a transaction. It pins a connection and starves the pool.
- Turn off
open-in-viewand 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.