Transactions with @Transactional
A transaction is an all-or-nothing unit of database work.
Introduction
A transaction is an all-or-nothing unit of database work. Spring's @Transactional annotation lets you mark a method so that the framework opens a transaction before it runs, commits on success, and rolls back on a runtime exception — without a single try/catch in your code.
This is arguably Spring's most loved feature and also its most misused one. Understanding propagation, rollback rules and the self-invocation trap is the difference between safe data and silent corruption.
Understanding the topic
The important knobs:
- Propagation — how an annotated method behaves if a transaction already exists (
REQUIRED,REQUIRES_NEW,NESTED,SUPPORTS,NEVER…). - Isolation — how strictly the DB protects you from concurrent writes (
READ_COMMITTED,REPEATABLE_READ,SERIALIZABLE). - Read-only — a hint that lets the driver and ORM skip flush/snapshot work.
- Rollback rules — by default only
RuntimeExceptiontriggers rollback; opt in for checked exceptions withrollbackFor. - Timeout — abort transactions that take longer than N seconds.
Syntax reference
A typical service method:
@Servicepublic class TransferService {private final AccountRepository accounts;public TransferService(AccountRepository accounts) {this.accounts = accounts;}@Transactionalpublic void transfer(UUID from, UUID to, Money amount) {accounts.debit(from, amount);accounts.credit(to, amount);// Any RuntimeException thrown here → both updates roll back.}}
Informative example
Propagation in action — sending an email even if the transfer rolls back:
@Serviceclass TransferService {private final AccountRepository accounts;private final Notifier notifier;@Transactionalpublic void transfer(UUID from, UUID to, Money amount) {accounts.debit(from, amount);accounts.credit(to, amount);notifier.queue(from, to, amount); // runs in its own tx}}@Serviceclass Notifier {@Transactional(propagation = Propagation.REQUIRES_NEW)public void queue(UUID from, UUID to, Money amount) {// Inserted into the outbox in an independent transaction;// the outer transfer can still roll back without losing this row.}}
REQUIRES_NEW suspends the outer transaction and starts a fresh one — useful for audit logs, outbox tables, and idempotency keys.
Real-world use
Most data corruption bugs in Spring apps come from misplacing @Transactional — either on a private method (proxy can't see it), on a method called via this (proxy bypassed), or with a checked exception that doesn't trigger rollback. Putting transactional boundaries on the service layer and reviewing them in PRs prevents almost all of these.
Best practices
- Put
@Transactionalon the service layer, not the repository or the controller. - Mark read paths
@Transactional(readOnly = true). - Keep transactional methods short — every line inside them holds DB locks.
- Be explicit about
rollbackForwhen you throw checked exceptions you want to roll back on.
Common mistakes
- Calling a
@Transactionalmethod from another method in the same class — the proxy is bypassed, no transaction starts. - Catching the exception inside the method silently — rollback never triggers.
- Putting
@Transactionalon aprivatemethod — proxies cannot intercept it. - Calling external services (HTTP, email) inside a transaction — you hold DB locks for the duration of the network call.
Hands-on exercise
Build it: create a BankService.transfer(from, to, amount) method that debits one account and credits another. Make the credit step throw a RuntimeException half the time. Run it in a loop and confirm that account balances always sum to the original total — proof that rollback works. Then move the @Transactional to a private method and watch the invariant break.