Skip to content

Spring Boot · Data Access

Declarative Transactions in Spring Boot, Explained

6 min read Updated 2026-06-26 Share:

Practice Transactions interview questions

One annotation, a lot of surprises

@Transactional looks like the simplest thing in Spring: annotate a method, get a transaction. But it's built on AOP proxies, it has rollback rules that contradict most people's intuition, and it silently does nothing in several common situations. This article is about the parts that bite — the behaviors an interviewer asks about precisely because they trip up real code.

What @Transactional actually does

Spring wraps the bean in a proxy that begins a transaction before your method, commits if it returns normally, and rolls back if it throws:

@Transactional
void transfer(Long from, Long to, BigDecimal amount) {
    accounts.debit(from, amount);
    accounts.credit(to, amount);   // throws here → the debit rolls back too
}

No manual commit()/rollback() — that's the "declarative" in declarative transaction management. Put the annotation on the service method that represents one unit of business work, not on controllers or individual repositories. A real operation usually touches several repositories, and they must share one transaction so they succeed or fail together.

The rollback rule nobody expects

By default, Spring rolls back on unchecked exceptions and Error — but commits on checked exceptions:

@Transactional
void process() throws IOException {
    repo.save(entity);
    throw new IOException("boom");   // CHECKED → transaction COMMITS, the save persists!
}

If a checked exception must roll back, say so explicitly:

@Transactional(rollbackFor = Exception.class)
void safe() throws IOException { ... }

This default is inherited from the old EJB convention and catches nearly everyone at least once. When in doubt, set rollbackFor.

Propagation: REQUIRED and REQUIRES_NEW

Propagation controls what happens when a transactional method calls another. The default, REQUIRED, joins an existing transaction or starts one:

@Transactional void outer() { inner(); }
@Transactional void inner() { ... }   // REQUIRED — joins outer's transaction

Both commit together; if inner throws and the exception propagates, the entire outer transaction rolls back, including work done before inner. The boundary is the outermost method, not each method.

REQUIRES_NEW is the deliberate exception — it suspends the current transaction and runs in an independent one:

@Transactional(propagation = Propagation.REQUIRES_NEW)
void record(AuditEvent e) { auditRepo.save(e); }   // commits even if the caller rolls back

It's how an audit log or status update survives a failure of the main work. The cost is a second connection held while the outer transaction is suspended, so use it sparingly. The other levels — MANDATORY, SUPPORTS, NOT_SUPPORTED, NEVER, NESTED — exist, but in practice REQUIRED and REQUIRES_NEW cover almost everything.

The proxy trap: self-invocation

Here's the bug that wastes an afternoon. @Transactional works through a proxy that wraps the bean from outside. A call from one method to another in the same bean bypasses the proxy — and the annotation does nothing:

@Service
class OrderService {
    void outer() {
        inner();          // direct this.inner() — proxy NOT involved → NO transaction
    }
    @Transactional
    void inner() { ... }
}

For the same reason, @Transactional is silently ignored on private methods (the proxy can't see them) and final methods (a CGLIB subclass proxy can't override them). The annotation must sit on a public, non-final method, and it only works when called from another bean. Fixes for self-invocation: move the method to a separate bean, inject the proxy into itself, or use TransactionTemplate.

Isolation: leave it alone unless you have a reason

Isolation controls which concurrency anomalies a transaction can observe, from READ_UNCOMMITTED (dirty reads) up to SERIALIZABLE (as if transactions ran one at a time):

@Transactional(isolation = Isolation.READ_COMMITTED)

Most databases default to READ_COMMITTED (MySQL InnoDB defaults to REPEATABLE_READ). Higher isolation removes anomalies but adds locking and contention. Stick with the database default and raise it only for a specific, justified consistency requirement.

readOnly: a real optimization

Marking a query method readOnly = true is more than documentation:

@Transactional(readOnly = true)
List<Order> listOrders() { return repo.findAll(); }

Hibernate sets the flush mode to manual — it skips dirty checking and won't flush — and the connection may be routed to a read replica or use a read-only optimization. Apply it to every read-path service method; it removes overhead and signals intent.

Flush is not commit

A subtle but important distinction. Flush sends the pending SQL to the database; commit ends the transaction and makes the changes durable and visible to others:

@Transactional
void demo() {
    repo.save(a);   // queued
    em.flush();     // SQL sent — but still inside the open transaction, still rollback-able
}                   // commit here: changes become permanent and visible

A flushed-but-uncommitted change is visible within the same transaction and can still be rolled back. Only commit releases locks and exposes the data to other transactions. Hibernate auto-flushes before queries that might be affected and at commit.

Locking: optimistic vs pessimistic

When two transactions race to update the same row, you pick a strategy. Optimistic locking assumes conflicts are rare — add a @Version column and check it on update:

@Entity class Account {
    @Id Long id;
    @Version int version;
    BigDecimal balance;
}
// UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?
// stale version → 0 rows updated → OptimisticLockException

No database locks are held, so it scales well; you handle the exception by retrying with fresh data. Pessimistic locking takes a real lock up front (SELECT ... FOR UPDATE) and makes others wait — right for hot rows where conflicts are frequent and retry loops would thrash:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findForUpdate(@Param("id") Long id);

It guarantees the latest data and exclusive access at the cost of contention, deadlock risk, and timeouts. Optimistic for the common low-conflict case; pessimistic for genuinely hot rows.

Transactions are thread-bound

A last gotcha: Spring binds the transaction and persistence context to the current thread. An @Async method or a manually spawned thread runs elsewhere and inherits nothing:

@Transactional
void parent() { child(); }     // @Async child runs on another thread

@Async
void child() {
    entity.getLazyField();     // no session → LazyInitializationException
}

The async method needs its own @Transactional if it touches the database, can't share the parent's uncommitted state, and won't roll back when the parent fails. Treat it as a fully independent unit of work, and never pass entities or lazy proxies across the thread boundary.

Recap

@Transactional on a public, non-final service method gives you declarative begin/commit/rollback — but remember checked exceptions commit by default (use rollbackFor), self-invocation and private/final methods silently get no transaction, REQUIRED shares one boundary while REQUIRES_NEW runs independently, readOnly = true skips dirty checking, flush isn't commit, and transactions don't cross thread boundaries. Pick optimistic locking for low contention and pessimistic for hot rows. The annotation is one line; knowing where it quietly does nothing is what separates working code from a 2 a.m. data bug.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel