← Back to the section

@Transactional is one short annotation that hides a lot of non-obvious machinery. As long as everything works you don't think about it; when it stops, the annotation turns out to behave differently than it seems. Let's start from scratch: what a transaction is, how Spring turns it on, and where the most common pitfalls are.

call from outside → via the wrapper call through this → past the wrapper controller Spring proxy BEGIN — open a transaction your bean OrderService processBatch() no annotation @Transactional processOne() this.processOne() COMMIT / ROLLBACK method finished — commit BEGIN and COMMIT never ran

It is not the method that opens a transaction, but the wrapper around the bean. A call from outside lands in the wrapper and gets its BEGIN and COMMIT; a call to the same method from a neighbouring one goes straight through — the wrapper never learns about it, and there is no transaction.

What a transaction is

Imagine a money transfer: withdraw from account A and deposit into account B. These are two database operations, but for the business they must be indivisible: either both go through, or neither does. If the withdrawal succeeds but the deposit fails, the money is gone.

A transaction is a group of database operations that runs on an "all or nothing" basis. Inside a transaction:

  • if everything succeeds, the changes are committed (commit);
  • if an error occurs, all changes are rolled back (rollback), as if they never happened.

Without a transaction, each INSERT/UPDATE is saved on its own, and if something fails midway you're left with partially written data.

Why you need @Transactional

Transactions used to be managed by hand: open one, commit at the end, roll back in catch, close the connection in finally. Verbose, and easy to forget a step.

Connection conn = dataSource.getConnection();
try {
    conn.setAutoCommit(false);
    accountRepo.withdraw(from, amount);
    accountRepo.deposit(to, amount);
    conn.commit();
} catch (Exception e) {
    conn.rollback();
    throw e;
} finally {
    conn.close();
}

@Transactional removes all this boilerplate: Spring opens a transaction before the annotated method, commits after it completes, and rolls back on error.

@Service
public class TransferService {

    @Transactional
    public void transfer(AccountId from, AccountId to, Money amount) {
        accountRepo.withdraw(from, amount);
        accountRepo.deposit(to, amount);
    }
}

If an exception is thrown inside transfer, both changes are rolled back automatically.

How it works: the proxy

Every pitfall grows from a single root: @Transactional works through a proxy.

A proxy is a wrapper object: when you mark a bean with the annotation, Spring puts a wrapper around it into the container instead of your object. The wrapper intercepts method calls: before the method it opens a transaction, after it commits or rolls back, and it calls the actual method "inside".

You can build such a wrapper by hand in plain Java — Spring does the same automatically. Watch which calls get a BEGIN and a COMMIT:

live example

import java.lang.reflect.Proxy;

public class ProxyDemo {

    interface Orders {
        void processBatch();
        void processOne(String id);
    }

    static class OrderService implements Orders {
        public void processBatch() {
            processOne("A-1");
        }
        public void processOne(String id) {
            System.out.println("    saving order " + id);
        }
    }

    static Orders wrap(Orders target) {
        return (Orders) Proxy.newProxyInstance(
                Orders.class.getClassLoader(),
                new Class<?>[]{Orders.class},
                (proxy, method, args) -> {
                    System.out.println("  BEGIN  " + method.getName());
                    Object result = method.invoke(target, args);
                    System.out.println("  COMMIT " + method.getName());
                    return result;
                });
    }

    public static void main(String[] args) {
        Orders orders = wrap(new OrderService());

        System.out.println("processOne through the proxy:");
        orders.processOne("A-9");

        System.out.println("processBatch through the proxy:");
        orders.processBatch();
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The output shows the whole point. processOne called from the outside gets both a BEGIN and a COMMIT. The very same processOne called from inside processBatch gets neither: the order is saved, but the wrapper never learns about the call. Everything hinges on the call going through the proxy — bypass it, and there is no transaction.

Pitfall 1: calling through this (self-invocation)

The most common one — and exactly what the example above printed. When a method calls another method of the same class through this (explicitly or implicitly: a bare processOne(...) is also this), the call goes directly, bypassing the proxy. The annotation on the second method won't fire.

The fix: move the @Transactional method into a separate bean and call it as a dependency — then the call will go through the proxy.

@Service
public class OrderService {
    private final OrderProcessor processor;   // another bean

    public void processBatch(List<Order> orders) {
        orders.forEach(processor::processOne);   // through the proxy — works
    }
}

Pitfall 2: private methods

The proxy can only wrap what it sees from the outside. On a private method @Transactional is silently ignored — with no error, which makes it especially treacherous.

@Service
public class OrderService {

    @Transactional   // ignored — the method is private
    private void save(Order order) { ... }
}

The rule is simple: put @Transactional on methods that are visible from outside the class. Since Spring 6 (Boot 3) a subclass-based proxy (CGLIB) also intercepts protected and package-visible methods, while an interface-based proxy still sees only public ones. A private method is never intercepted — nor is a final method or a method of a final class: the proxy inherits from the class and cannot override them. Rather than sorting out the subtleties, keep transactional methods public.

Pitfall 3: a swallowed exception

A rollback happens only if the exception leaves the method and reaches the proxy. If you caught it inside a try/catch and didn't rethrow it, Spring never learns about the failure and commits.

@Transactional
public void process(Order order) {
    try {
        repo.save(order);
        externalCall();   // failed here
    } catch (Exception e) {
        log.warn("error", e);   // caught and swallowed — there will be NO rollback
    }
}

If you need a rollback, the exception has to be rethrown.

Default rollback: only unchecked

Even when an exception is thrown to the outside, a rollback doesn't happen for just any exception. By default Spring rolls the transaction back only on RuntimeException and Error (so-called unchecked exceptions).

On a checked exception (one that must be declared in throws — for example IOException) the transaction is committed by default. Spring's reasoning is this: a checked exception is part of the "normal" scenario, not a failure.

The whole rule is literally two catch blocks. Here they are in plain Java, together with the third case from the previous section:

live example

import java.io.IOException;

public class RollbackDemo {

    interface Body {
        void run() throws Exception;
    }

    static void inTransaction(String name, Body body) {
        System.out.println("BEGIN " + name);
        try {
            body.run();
            System.out.println("  COMMIT");
        } catch (RuntimeException | Error e) {
            System.out.println("  ROLLBACK: " + e.getMessage());
        } catch (Exception checked) {
            System.out.println("  COMMIT, even though it threw " + checked.getClass().getSimpleName());
        }
    }

    public static void main(String[] args) {
        inTransaction("unchecked", () -> {
            throw new IllegalStateException("item is out of stock");
        });
        inTransaction("checked", () -> {
            throw new IOException("file is unavailable");
        });
        inTransaction("swallowed", () -> {
            try {
                throw new IllegalStateException("item is out of stock");
            } catch (RuntimeException swallowed) {
                System.out.println("  caught it and kept quiet");
            }
        });
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Three runs, three outcomes, and only the first rolls back. Two ways to fix it:

  • explicitly state what to roll back on: @Transactional(rollbackFor = Exception.class);
  • in your own code, throw subclasses of RuntimeException — then rollback works "out of the box".

Propagation — what to do with an already-open transaction

Propagation answers the question: a method was called inside an already-running transaction — should it join that one or open its own? There are seven modes; three matter in practice.

REQUIRED — the default

"If there's a transaction, join it; if not, open a new one." Everything runs in a single transaction with a shared commit/rollback. This is the default behavior, and 90% of methods get it.

@Transactional   // REQUIRED
public void placeOrder(Order order) {
    orderRepo.save(order);
    auditService.log(order);   // if log is also @Transactional — the same transaction
}

Consequence: if something fails at the end, everything rolls back, including the audit record.

REQUIRES_NEW — always its own transaction

The current transaction is suspended, a new and independent one is opened. It commits on its own, after which the original transaction continues.

@Transactional
public void process(Order order) {
    orderRepo.save(order);
    auditService.log(order);            // REQUIRES_NEW — a separate transaction
    throw new RuntimeException("...");  // the main one rolls back,
                                        // but the audit record is already saved
}

class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(Order order) { ... }
}

Needed for independent side actions — audit, journal, notification: "the main task failed, but the fact of the attempt must remain".

NESTED — a savepoint inside a transaction

Creates a savepoint inside the current transaction. If the nested chunk fails, only the part up to the savepoint is rolled back, not the whole transaction.

@Transactional
public void processBatch(List<Order> orders) {
    for (var order : orders) {
        try {
            processor.tryProcess(order);   // NESTED
        } catch (Exception e) {
            log.warn("skipped: {}", order.id());   // one bad item doesn't wreck the whole batch
        }
    }
}

What matters is that the nested method really is NESTED. With a plain REQUIRED the caught exception would still mark the shared transaction rollback-only, and the outer method would fail at commit with UnexpectedRollbackException — the whole batch would go down.

NESTED works where the transaction manager knows about savepoints: DataSourceTransactionManager (JDBC on top of PostgreSQL) does, while JpaTransactionManager throws NestedTransactionNotSupportedException by default — a savepoint would roll back the connection but not the JPA entity cache.

The other four — briefly

  • MANDATORY — requires an already-open transaction, otherwise an error. "I must not be called outside a transaction."
  • SUPPORTS — if there's a transaction, I'll join it; if not, I'll run without one.
  • NOT_SUPPORTED — suspend the transaction and run without it.
  • NEVER — if a transaction exists, throw an error.

Isolation — how transactions see one another

Isolation determines how much parallel transactions "see" each other's uncommitted changes. The higher the level, the fewer strange effects from concurrency — and the higher the cost.

@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }

There are four levels plus DEFAULT (taken from the database settings — in PostgreSQL that's READ_COMMITTED). The default is enough for most tasks, and changing the level should be a deliberate decision — details are in the article on ACID in PostgreSQL.

One caveat: at high levels (SERIALIZABLE) the database may reject one of two conflicting transactions with an error. Such code must be able to retry, otherwise you get random failures under load.

readOnly = true — the "read only" marker

If a method only reads and writes nothing, mark the transaction as read-only:

@Transactional(readOnly = true)
public List<Order> findByCustomer(CustomerId id) {
    return repo.findByCustomerId(id);
}

Why you'd want this (especially with JPA/Hibernate):

  • Hibernate turns off change tracking — it doesn't compare objects "before and after" and doesn't issue extra queries;
  • if the application is set up to route reads to a replica (usually through AbstractRoutingDataSource), it is the readOnly flag that tells it the query can go there.

It's a cheap win, and worth putting on every read-only method.

Multiple databases, or a database plus Kafka — why "one transaction" won't work

Sometimes you want to wrap two databases, or a database and sending a message to a queue, into a single transaction. Technically Spring has mechanisms for this, but in practice it's not done:

  • it's slow — you need synchronous coordination between systems;
  • it's fragile — if one of the systems hangs, the transaction is left in an undefined state;
  • many cloud databases don't support this mode.

The correct approach for such cases is the Outbox pattern (write an "outgoing" table into the same database, and a separate process then dispatches the messages) and Saga. Details are in Distributed Patterns.

In short

  • A transaction is a group of database operations on an "all or nothing" basis; @Transactional removes the manual boilerplate: Spring opens, commits, and rolls it back for you.
  • It works through a proxy: the call must go through the wrapper. A call through this, a private or final method bypasses it — and there is simply no transaction there.
  • An exception swallowed by try/catch never reaches the proxy, so the transaction commits.
  • By default the rollback happens only on RuntimeException/Error; on checked exceptions the transaction commits. Fixed via rollbackFor.
  • Propagation: REQUIRED (the default), REQUIRES_NEW (a separate transaction for audit records), NESTED (a savepoint, and only where the manager supports one).
  • Change Isolation deliberately and retry the operation on a conflict; readOnly = true goes on every read-only method; two databases or a database plus a queue mean Outbox and Saga, not one transaction.