Without correct transaction boundaries, partial updates corrupt data under failures.
Annotate a service method with @Transactional and Spring starts a transaction before the method and commits after it returns. If it throws, the transaction is rolled back. Everything the method does through repositories (and anything it calls in the same thread) shares that transaction and its database connection.
Rules that matter:
- By default only unchecked exceptions (
RuntimeException,Error) trigger rollback. A checked exception commits unless you addrollbackFor = Exception.class. @Transactional(readOnly = true)for query methods lets Hibernate skip dirty checking and lets the driver route to replicas.- Propagation decides what happens when a transactional method calls another: the default
REQUIREDjoins the existing transaction;REQUIRES_NEWsuspends it and starts an independent one, useful for audit logs that must persist even if the outer work fails. - Inside a transaction, loaded entities are managed: changing a field is written automatically at commit (dirty checking), no
savecall needed.
Transactions are implemented with proxies: the annotation only works when the method is called through the Spring bean from another bean. A call from one method to another @Transactional method in the same class bypasses the proxy, so the annotation on the inner method is ignored. Also, annotated methods should be public (Spring 6 also supports protected and package-private methods with class-based proxies).
Put transaction boundaries on service methods representing one use case, not on controllers or repositories.
Example
@Service
class TransferService {
private final AccountRepository accounts;
private final AuditService audit;
TransferService(AccountRepository accounts, AuditService audit) {
this.accounts = accounts; this.audit = audit;
}
@Transactional
public void transfer(long from, long to, BigDecimal amount) {
Account a = accounts.findById(from).orElseThrow();
Account b = accounts.findById(to).orElseThrow();
audit.record("transfer " + amount); // REQUIRES_NEW: survives rollback
a.withdraw(amount); // dirty checking: no save() needed
b.deposit(amount);
if (a.getBalance().signum() < 0)
throw new IllegalStateException("insufficient funds"); // rolls back both
}
}
@Service
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event) { /* insert audit row */ }
}Remember
@Transactionalcommits on return and rolls back on unchecked exceptions- Checked exceptions commit unless
rollbackForsays otherwise REQUIREDjoins,REQUIRES_NEWstarts an independent transaction- Self-invocation bypasses the proxy and the annotation
Watch out for
- Calling a @Transactional method from another method in the same class and expecting a new transaction.