PrepZone Logo
PrepZone

@Transactional Deep Dive

Transaction boundaries, propagation levels, isolation and rollback rules.

Why this matters

  • Without transactions, a BookStore order that deducts inventory and creates an order record can leave inconsistent data if the second step fails.
  • Propagation and isolation levels determine how nested service calls interact — wrong settings cause subtle bugs.
  • @Transactional only works on Spring-managed beans called through the proxy — self-invocation bypasses it.

Basic usage

Java
@Service
public class OrderService {
    private final BookRepository bookRepository;
    private final OrderRepository orderRepository;

    @Transactional
    public Order placeOrder(Long bookId, int quantity) {
        Book book = bookRepository.findById(bookId)
            .orElseThrow(() -> new BookNotFoundException(bookId));

        if (book.getStock() < quantity) {
            throw new InsufficientStockException(bookId, quantity);
        }

        book.setStock(book.getStock() - quantity);
        bookRepository.save(book);

        Order order = new Order(book, quantity);
        return orderRepository.save(order);
    }
}

If orderRepository.save() fails, the stock deduction rolls back automatically.

Transaction proxy
BEGIN transaction
bookService.placeOrder()Your code
COMMIT or ROLLBACK
Spring wraps your service in a proxy that opens/commits/rolls back the transaction around your method.

Read-only transactions

Java
@Service
@Transactional(readOnly = true)
public class BookService {
    public List<Book> findAll() {
        return repository.findAll();
    }

    @Transactional  // overrides class-level readOnly for writes
    public Book create(CreateBookRequest request) {
        return repository.save(new Book(...));
    }
}

readOnly = true hints Hibernate to skip dirty checking and can route to read replicas.

Propagation levels

Propagation behaviours

  • REQUIRED (default) — Join existing transaction or create a new one.
  • REQUIRES_NEW — Always create a new transaction; suspend the current one.
  • NESTED — Nested transaction with savepoint; partial rollback possible.
  • SUPPORTS — Join if exists, run non-transactionally otherwise.
  • NOT_SUPPORTED — Suspend current transaction, run without one.
  • MANDATORY — Must run inside an existing transaction or throw exception.
  • NEVER — Must not run inside a transaction or throw exception.

REQUIRES_NEW example

Audit logging that must persist even if the main transaction rolls back:

Java
@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAction(String action, String details) {
        auditRepository.save(new AuditLog(action, details, Instant.now()));
    }
}

The audit record commits independently of the calling transaction.

Rollback rules

By default, @Transactional rolls back only on unchecked exceptions (RuntimeException). Checked exceptions commit:

Java
@Transactional(rollbackFor = {InsufficientStockException.class})
public Order placeOrder(...) throws InsufficientStockException { ... }

Or globally:

Java
@Transactional(rollbackFor = Exception.class)

Isolation levels

Java
@Transactional(isolation = Isolation.READ_COMMITTED)
public Book updatePrice(Long id, BigDecimal newPrice) { ... }
LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented
  • READ_UNCOMMITTED

    Dirty readPossible
    Non-repeatable readPossible
    Phantom readPossible
  • READ_COMMITTED

    Dirty readPrevented
    Non-repeatable readPossible
    Phantom readPossible
  • REPEATABLE_READ

    Dirty readPrevented
    Non-repeatable readPrevented
    Phantom readPossible
  • SERIALIZABLE

    Dirty readPrevented
    Non-repeatable readPrevented
    Phantom readPrevented

PostgreSQL defaults to READ_COMMITTED. Only raise isolation when you have proven concurrency issues.

Programmatic transactions

When annotations are impractical:

Java
@Service
public class BulkImportService {
    private final TransactionTemplate transactionTemplate;

    public BulkImportService(PlatformTransactionManager txManager) {
        this.transactionTemplate = new TransactionTemplate(txManager);
    }

    public int importBooks(List<CreateBookRequest> books) {
        return transactionTemplate.execute(status -> {
            int count = 0;
            for (CreateBookRequest req : books) {
                repository.save(new Book(req.title(), req.isbn(), req.price()));
                count++;
            }
            return count;
        });
    }
}

Transaction boundaries in BookStore

LayerTransactional?Why
ControllerNoHTTP mapping only
ServiceYesBusiness logic + data consistency
RepositoryInheritedParticipates in caller's transaction
  • Controller

    Transactional?No
    WhyHTTP mapping only
  • Service

    Transactional?Yes
    WhyBusiness logic + data consistency
  • Repository

    Transactional?Inherited
    WhyParticipates in caller's transaction

Quick recall

Everything you need if you only revisit this box.

  • @Transactional on service methods ensures all-or-nothing database operations.
  • readOnly = true optimises read paths and enables read-replica routing.
  • Propagation REQUIRED joins or creates; REQUIRES_NEW creates an independent transaction.
  • Default rollback is on unchecked exceptions only — use rollbackFor for checked ones.
  • Self-invocation and private methods bypass the transactional proxy.
  • Keep transaction boundaries in the service layer, never in controllers or repositories.

Test yourself

Answer these before moving on — recall is what makes it stick.