Transactions with @Transactional¶
A transaction makes a group of database operations atomic: all of them happen, or none do. Spring lets you declare transaction boundaries with one annotation. The annotation is simple; understanding where it takes effect is what prevents production bugs.
Where the boundary belongs¶
Put @Transactional on service methods that represent one business operation:
@Service
public class LoanService {
private final BookRepository books;
private final LoanRepository loans;
private final MemberRepository members;
// constructor...
@Transactional
public Loan lend(long bookId, long memberId) {
Book book = books.findById(bookId).orElseThrow(() -> new BookNotFoundException(bookId));
Member member = members.findById(memberId).orElseThrow();
if (loans.existsByBookIdAndReturnedAtIsNull(bookId)) {
throw new BookAlreadyLentException(bookId);
}
if (member.activeLoans() >= member.loanLimit()) {
throw new LoanLimitReachedException(memberId);
}
member.incrementActiveLoans(); // dirty-checked
return loans.save(new Loan(book, member));
}
}
The checks and the writes happen in one transaction on one connection. If saving the loan fails, the member's counter change is rolled back too.
Controllers should not be transactional (the transaction would span JSON serialization and response writing), and repositories already are, per call — which is not enough when one operation spans several calls.
Read-only transactions¶
readOnly = true tells Hibernate it can skip dirty checking and snapshots (less memory
and CPU), sets the JDBC connection read-only (some drivers and databases optimize or
route to replicas based on it), and documents intent. A common pattern is
@Transactional(readOnly = true) on the class and plain @Transactional on the
methods that write.
Rollback rules¶
By default Spring rolls back on unchecked exceptions (RuntimeException, Error)
and commits on checked exceptions. That surprises people:
@Transactional
public void importCatalog(Path file) throws IOException {
books.save(...);
Files.readAllLines(file); // throws IOException → the save above is COMMITTED
}
Either make your domain exceptions unchecked (the common choice in Spring code), or say
what you want: @Transactional(rollbackFor = IOException.class).
Catching an exception inside the transactional method means it never reaches the
interceptor, so no rollback happens — unless the exception came from a nested
transactional call that already marked the transaction rollback-only, in which case the
commit fails with UnexpectedRollbackException. If you catch, decide deliberately.
Propagation¶
When a transactional method calls another transactional method, propagation decides
what happens:
| Propagation | Behavior |
|---|---|
REQUIRED (default) |
Join the existing transaction, or start one |
REQUIRES_NEW |
Suspend the current transaction and run in a brand-new one (separate connection) |
MANDATORY |
Must be called inside a transaction, else error |
SUPPORTS |
Join if one exists, otherwise run without |
NOT_SUPPORTED |
Suspend any transaction and run without |
NESTED |
Savepoint within the current transaction; needs a transaction manager that supports savepoints, so verify it with your setup before relying on it |
REQUIRES_NEW is the one you will actually reach for — for an audit record that must be
saved even if the main operation rolls back. Use it sparingly: each one holds a second
connection while the first is suspended, and under load that can exhaust the pool.
Worked example: the self-invocation trap¶
This class from the project has one transactional method and one plain method that calls it:
@Service
public class CatalogService {
public boolean outerWithoutTx() {
return innerWithTx(); // calling a method on `this`
}
@Transactional
public boolean innerWithTx() {
return TransactionSynchronizationManager.isActualTransactionActive();
}
}
A test called both from outside the class. Real output (Boot 4.1):
innerWithTx via proxy: true
innerWithTx via self-invocation: false
proxy class: com.example.library.CatalogService$$SpringCGLIB$$0
Calling innerWithTx() directly on the injected bean ran it in a transaction. Calling
it through another method of the same class did not — the @Transactional annotation
was silently ignored. The last line shows why: the object injected into the test is not a
CatalogService but a generated subclass.
How It Actually Works¶
At startup, a BeanPostProcessor (the auto-proxy creator) inspects every bean. When it
finds @Transactional on a class or method, it replaces the bean with a proxy — a
CGLIB subclass by default in Boot — that wraps the real object. Every other bean receives
the proxy.
When a caller invokes innerWithTx() on the proxy, the proxy's TransactionInterceptor:
- Reads the transaction attributes (propagation, isolation, readOnly, rollback rules).
- Asks the
PlatformTransactionManager(aJpaTransactionManagerhere) for a transaction — which gets a connection from the pool, sets auto-commit off, and binds the connection and theEntityManagerto the current thread viaTransactionSynchronizationManager(a set ofThreadLocals). - Calls the real method on the target object.
- On normal return, flushes the persistence context and commits; on an exception matching the rollback rules, rolls back; then unbinds the thread-locals and returns the connection.
The critical detail is step 3: inside the real object, this is the target, not the
proxy. So outerWithoutTx() → this.innerWithTx() is an ordinary Java call that never
passes through the interceptor. Fixes, in order of preference:
- Move the transactional method to a different bean and inject it.
- Make the outer method the transactional one (usually the right boundary anyway).
- Use
TransactionTemplatefor programmatic control where a boundary must sit inside a method.
The same proxy mechanism explains other rules: @Transactional has no effect on
private methods (a subclass cannot override them), on final methods, or on calls made
during the constructor. And because the transaction is bound to a ThreadLocal, work
handed to another thread (@Async, CompletableFuture.supplyAsync) does not run inside
it. Level 3 generalizes all of this to AOP.
Common mistakes¶
- Self-invocation, as shown. The most common transaction bug in Spring code.
@Transactionalon private methods — ignored.- Checked exceptions expected to roll back — they do not by default.
- Long transactions that call remote HTTP services while holding a database connection and row locks. Do remote calls before or after the transaction, or use the outbox pattern (Level 3).
- Importing the wrong annotation. Prefer
org.springframework.transaction.annotation.Transactional;jakarta.transaction.Transactionalis also honored but supports fewer options.
Exercise¶
- Reproduce the self-invocation test above in your project, then fix it by moving
innerWithTxinto a separate bean. - Write a service method that saves a book and then throws a checked exception. Show
with a test that the book was committed. Add
rollbackForand show it is not. - Implement an
AuditService.record(...)withREQUIRES_NEWand demonstrate that the audit row survives when the calling transaction rolls back. - Add
@VersiontoBook, load the same book in two separate transactions, modify and save both, and show the second one fails with an optimistic locking exception. Map it to 409 in your exception handler.