Skip to content
Spring Boot sb data-jpa 4 min read

@Transactional

A transaction groups several database operations into a single atomic unit: they all succeed or all roll back. Spring’s @Transactional (from org.springframework.transaction.annotation) turns this into a single declarative annotation, wrapping your method in a transaction managed by a proxy. Used well it guarantees consistency; used carelessly it silently does nothing. This page covers where to place it, how propagation and isolation behave, the default rollback rules, and the self-invocation trap that catches almost everyone.

Where to Put @Transactional

Put @Transactional on the service layer, not on repositories or controllers.

  • Repositories already run each method in a transaction, but they are too fine-grained — a business operation usually spans multiple repository calls that must commit together.
  • Controllers should stay free of persistence concerns; keeping transactions out of the web layer also avoids holding a database connection while serializing the response.
  • The service is where a business use case lives, so it is the natural transaction boundary.
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class TransferService {

    private final AccountRepository accountRepository;

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();
        from.withdraw(amount);
        to.deposit(amount);
        // both updates commit together, or neither does
    }
}

Tip: Use constructor injection with @RequiredArgsConstructor so the bean is immutable and easy to test. See dependency injection for how Spring wires this proxy-backed bean.

Propagation

Propagation decides how a method joins or creates a transaction relative to one that may already be running. The default, REQUIRED, covers most cases.

PropagationIf a transaction existsIf none exists
REQUIREDjoin it (default)create a new one
REQUIRES_NEWsuspend it, start a newcreate a new one
SUPPORTSjoin itrun non-transactional
MANDATORYjoin itthrow an exception
NESTEDnested savepointcreate a new one
NEVERthrow an exceptionrun non-transactional
NOT_SUPPORTEDsuspend it, run withoutrun non-transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditLog(AuditEntry entry) {
    auditRepository.save(entry);  // commits even if the caller later rolls back
}

Note: REQUIRES_NEW is ideal for audit logs or notifications that must persist regardless of the outer transaction’s outcome.

Isolation Levels

Isolation controls how concurrent transactions see each other’s uncommitted changes, trading consistency against throughput.

  • DEFAULT — use the database’s own default (usually READ_COMMITTED on PostgreSQL/MySQL InnoDB).
  • READ_COMMITTED — sees only committed data; prevents dirty reads.
  • REPEATABLE_READ — re-reads return the same rows; prevents non-repeatable reads.
  • SERIALIZABLE — full isolation, as if transactions ran one at a time; strongest and slowest.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Report buildReport() { ... }

readOnly Optimization

Mark query-only methods with readOnly = true. Hibernate then sets the flush mode to MANUAL, skipping dirty-checking and snapshot tracking, and the driver/database can apply read-path optimizations.

@Transactional(readOnly = true)
public List<AccountView> listAccounts() {
    return accountRepository.findAllProjectedBy();
}

Tip: Make every read-only service method readOnly = true. It is a cheap, safe performance win and documents intent.

Rollback Rules

By default Spring rolls back only on RuntimeException and Errornot on checked exceptions. A checked exception escaping a @Transactional method commits the transaction.

@Transactional
public void process() throws IOException {
    repo.save(entity);
    throw new IOException("boom");  // checked -> transaction STILL COMMITS
}

Override the behavior with rollbackFor / noRollbackFor:

@Transactional(rollbackFor = IOException.class)
public void process() throws IOException { ... }   // now rolls back

@Transactional(noRollbackFor = ValidationException.class)
public void save() { ... }                          // commit despite this runtime exception

Warning: Catching an exception inside the method and not rethrowing it means Spring never sees it — the transaction commits. If you handle and want a rollback anyway, call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). For exception design, see Java exceptions.

The Self-Invocation Pitfall

@Transactional works through a Spring AOP proxy: Spring wraps your bean in a proxy that opens the transaction before delegating to your real method. The proxy only intercepts calls that arrive through it — i.e. calls from other beans.

When a method calls another @Transactional method on the same bean (this.other()), the call goes straight to the target object and bypasses the proxy entirely, so the annotation is silently ignored.

@Service
@RequiredArgsConstructor
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        saveAudit(order);   // self-invocation -> @Transactional below is IGNORED
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAudit(Order order) {  // never runs in its own transaction here
        auditRepository.save(new AuditEntry(order));
    }
}

How to fix it

  • Move the method to a separate bean and inject it, so the call crosses the proxy boundary (the cleanest fix).
  • Self-injection — inject the bean into itself and call through that reference:
@Service
@RequiredArgsConstructor
public class OrderService {

    private final ApplicationContext context;

    @Transactional
    public void placeOrder(Order order) {
        context.getBean(OrderService.class).saveAudit(order);  // goes through the proxy
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAudit(Order order) { ... }
}

Understanding why this happens requires the bean/proxy model explained in dependency injection.

Last updated June 13, 2026
Was this helpful?