How it works
The explanation, with examples you can run.
What the proxy does
@Serviceclass TransferService { private final AccountRepository accounts; TransferService(AccountRepository accounts) { this.accounts = accounts; } @Transactional public void transfer(long from, long to, BigDecimal amount) { accounts.debit(from, amount); accounts.credit(to, amount); // if this throws, the debit is undone too } @Transactional(readOnly = true) public BigDecimal balance(long id) { return accounts.balanceOf(id); }}In pseudo-code, the proxy's version of transfer is: get or start a transaction, call the real method, commit; on RuntimeException or Error, roll back and rethrow. readOnly = true is an optimisation hint: Hibernate can skip dirty checking, and the JDBC connection is flagged read-only, which some setups use to route to a replica. It is not a security boundary.
The self-invocation trap
Because the behaviour lives in the proxy, a call on this bypasses it:
public void importAll(List<Row> rows) { for (Row r : rows) importOne(r); // no transaction per row: this.importOne} @Transactionalpublic void importOne(Row r) { ... }Fixes, in order of preference: put the annotation on the outer method if that is the unit you want; move importOne to a separate bean; or use a TransactionTemplate to make the boundary explicit in code. The same trap applies to @Async, @Cacheable and @PreAuthorize.
Other quiet failures of the same kind: the bean was created with new instead of by the container, or the method is private or final, which a subclass proxy cannot override. Since Spring 6.0, protected and package-private methods are proxied for class-based proxies, but public remains the safe habit.
Rollback rules
@Transactional(rollbackFor = PaymentDeclinedException.class)public void charge(...) throws PaymentDeclinedException { ... }Checked exceptions commit by default. Catching an exception inside the method and not rethrowing also means commit, because the proxy never sees it.
Propagation
| Setting | Behaviour |
|---|---|
REQUIRED (default) | Join the current transaction, or start one |
REQUIRES_NEW | Suspend the current one, start an independent one |
MANDATORY | Fail if there is no current transaction |
NOT_SUPPORTED | Suspend the current one, run without |
NESTED | One physical transaction with a savepoint the inner scope can roll back to (needs JDBC savepoint support) |
One subtlety with REQUIRED: if an inner method marks the shared transaction rollback-only and the outer method catches the exception and carries on, the commit at the end fails with UnexpectedRollbackException. The inner and outer methods are one physical transaction; they succeed or fail together.
Keep transactions short
A transaction holds a pooled connection from start to finish. Calling a slow HTTP service inside one ties up a connection for the duration; with 10 connections, ten slow calls stall every other request that needs the database. Do the remote call first, then open the transaction to write the result.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.