Spring Framework Tutorial 0/14 lessons ~6 min read Lesson 10

    Transactions with @Transactional

    A transaction is an all-or-nothing unit of database work.

    Course progress0%
    Focus
    8 guided sections
    Practice signal
    Examples included
    Career prep
    Foundation builder

    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 RuntimeException triggers rollback; opt in for checked exceptions with rollbackFor.
    • Timeout — abort transactions that take longer than N seconds.

    Syntax reference

    A typical service method:

    java
    @Service
    public class TransferService {
    private final AccountRepository accounts;
    public TransferService(AccountRepository accounts) {
    this.accounts = accounts;
    }
    @Transactional
    public 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:

    java
    @Service
    class TransferService {
    private final AccountRepository accounts;
    private final Notifier notifier;
    @Transactional
    public 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
    }
    }
    @Service
    class 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 @Transactional on 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 rollbackFor when you throw checked exceptions you want to roll back on.

    Common mistakes

    • Calling a @Transactional method 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 @Transactional on a private method — 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.

    Ready to mark this lesson complete?Track your journey across the entire course.