← Back to the section

You put @Transactional on a method — and it feels like everything should just work. But sometimes the transaction doesn't roll back on error, or opens where you didn't expect it. Let's look at how it's built and where it breaks.

How @Transactional works at all

Spring doesn't magically "see" the annotation at runtime. It wraps your class in a proxy — a wrapper object. When a call from outside reaches a method with @Transactional, the proxy intercepts it, opens a transaction, runs the original method, then commits or rolls back. Every limitation that looks strange follows from this one mechanism.

Where to put @Transactional

The annotation lives on methods of the service layer — where one operation makes several related changes in the database.

@Component
class CreateOrderHandler {
    @Transactional
    public OrderId handle(CreateOrderCommand cmd) {
        var customer = customerRepository.findById(cmd.customerId());
        var order = orderFactory.create(customer, cmd.items());
        orderRepository.save(order);
        outboxRepository.publishEvent(new OrderCreated(order.id()));
        return order.id();
    }
}

The transaction guarantees: either the order and the outbox event are saved — or nothing.

Common placement mistakes:

  • On the controller — an HTTP request and a business operation are different things: the transaction bounds the business action, not the HTTP cycle.
  • On the repository — redundant: Spring Data already wraps every method, but it won't combine several operations into one — for that the annotation must sit higher.
  • On a private method — the proxy cannot override it, so the annotation is silently ignored. Since Spring 6 (Boot 3) protected and package-private methods do work, but private never will — keep transactional methods public.

The main trap: self-invocation

The most common reason why @Transactional "doesn't work". When a method calls another method of the same class through this, the call goes directly, bypassing the wrapper.

@Component
class OrderService {
    public void processBatch(List<OrderId> ids) {
        for (var id : ids) {
            processOne(id);   // call through this — the proxy is not involved!
        }
    }

    @Transactional
    public void processOne(OrderId id) {
        // the transaction will NOT open
    }
}

The fix: move the method into a separate bean, so the call goes through the proxy:

@Component
class OrderService {
    private final OrderProcessor processor;

    public void processBatch(List<OrderId> ids) {
        for (var id : ids) {
            processor.processOne(id);   // through DI — through the proxy — the transaction opens
        }
    }
}

@Component
class OrderProcessor {
    @Transactional
    public void processOne(OrderId id) { ... }
}

Propagation modes

Propagation decides what happens if a transaction is already open when a @Transactional method is called.

ModeBehavior
REQUIRED (default)If there's a transaction — join it. If not — open a new one.
REQUIRES_NEWSuspend the current transaction and open a new, separate one.
NESTEDCreate a savepoint inside the current transaction.
MANDATORYA transaction must already be open, otherwise — an exception.

The first three differ in how many connections are busy and what survives a rollback of the outer transaction.

outer transaction is open — what the inner @Transactional does REQUIRED BEGIN inner callCOMMITone connection,one commit for all REQUIRES_NEW BEGIN suspendedROLLBACKCOMMIT · conn. 2second connectionfrom the pool; itscommit is final NESTED BEGIN SAVEPOINT, rollbackCOMMITsame connection,outer one survives

Three modes, three fates for the inner call: join the same transaction, move to its own connection, or place a savepoint inside the current one. A rollback of the outer transaction undoes the first and third case and leaves the second untouched.

In most cases you need REQUIRED.

REQUIRES_NEW — when you need a separate transaction

A typical example: an audit log. The entry must survive even if the main operation failed and rolled back.

@Transactional
public void processPayment(PaymentRequest req) {
    auditService.logAttempt(req);   // must be written even on error
    paymentGateway.charge(req);
}

@Component
class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAttempt(PaymentRequest req) { ... }
}

REQUIRES_NEW takes a separate connection from the pool while the current one stays busy — under high load that doubles pool usage.

NESTED — rolling back part of the operation

To try something and roll back only that part on error, without touching the rest:

@Transactional
public void importBatch(List<Item> items) {
    for (var item : items) {
        try {
            processor.saveItem(item);
        } catch (Exception e) {
            log.warn("skipping {}: {}", item, e.getMessage());
        }
    }
}

@Component
class ItemProcessor {
    @Transactional(propagation = Propagation.NESTED)
    public void saveItem(Item item) { ... }
}

The savepoint rollback leaves the outer transaction alive — the remaining items keep being processed.

One condition is easy to forget: savepoints come from DataSourceTransactionManager (JDBC, jOOQ); JpaTransactionManager disallows them by default and throws NestedTransactionNotSupportedException.

readOnly = true

@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }

The readOnly flag has three effects:

  1. JDBC sends SET TRANSACTION READ ONLY — a write inside such a transaction fails, so an accidental UPDATE in a reporting method shows up at once.
  2. Hibernate (JPA) disables dirty checking and flush — a little less work for the CPU.
  3. With a routing DataSource, the connection goes to the read replica automatically.

Why checked exceptions don't roll back the transaction

A historical decision in Spring: by default the transaction rolls back only on RuntimeException and Error. Checked exceptions (IOException, SQLException) do not roll it back — it commits.

The whole rule fits into one try — here it is in plain Java:

live example

import java.io.IOException;
import java.util.concurrent.Callable;

public class RollbackRuleDemo {

    static void inTransaction(String method, Callable<Void> body) {
        System.out.println("BEGIN    " + method);
        try {
            body.call();
            System.out.println("COMMIT   " + method);
        } catch (RuntimeException | Error e) {
            System.out.println("ROLLBACK " + method + "  <- " + e.getClass().getSimpleName());
        } catch (Exception checked) {
            System.out.println("COMMIT   " + method + "  <- " + checked.getClass().getSimpleName()
                    + ", the default rule does not roll back on this one");
        }
    }

    public static void main(String[] args) {
        inTransaction("saveReport", () -> { throw new IOException("file was not written"); });
        inTransaction("payOrder", () -> { throw new IllegalStateException("order is already paid"); });
    }
}
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 method failed — and the output says COMMIT: the data is half written, and nobody noticed.

Two ways to fix this:

Option 1 — explicitly specify rollbackFor:

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

Option 2 — wrap in a RuntimeException (often preferred):

@Transactional
public void doWork() {
    try {
        externalCall();
    } catch (IOException e) {
        throw new ExternalCallFailedException(e);   // RuntimeException
    }
}

The second option is a clearer contract: no checked exceptions in the signature, errors are runtime, behavior predictable.

Transactions and async

A transaction lives in a thread: Spring keeps the connection and the "transaction is open" flag in a ThreadLocal. In another thread none of that is there:

live example

public class TxContextDemo {

    static final ThreadLocal<String> CURRENT_TX = new ThreadLocal<>();

    static void report(String where) {
        String tx = CURRENT_TX.get();
        System.out.println(where + ": " + (tx == null ? "no transaction" : "transaction " + tx));
    }

    public static void main(String[] args) throws InterruptedException {
        CURRENT_TX.set("tx-42");
        report("request thread");

        Thread worker = new Thread(() -> report("@Async thread"));
        worker.start();
        worker.join();

        CURRENT_TX.remove();
    }
}
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 →

That's why an asynchronous method opens its own transaction instead of continuing the parent one:

@Component
class OrderService {
    private final Notifier notifier;   // separate bean: the call goes through the proxy

    @Transactional
    public void mainOp() {
        orderRepo.save(...);
        notifier.notifyAsync();   // leaves for another thread, with its own transaction
    }
}

@Component
class Notifier {
    @Async
    @Transactional
    public void notifyAsync() { ... }
}

@Async rides on the same proxy as @Transactional: left in OrderService, notifyAsync would never have reached another thread and would have run right inside the mainOp transaction.

To do something after a successful commit, use @TransactionalEventListener:

@Component
class OrderEventHandler {
    @TransactionalEventListener   // AFTER_COMMIT by default
    public void onOrderCreated(OrderCreated event) {
        emailService.send(...);
    }
}

The listener fires only if the transaction committed — no risk of sending an email on rollback.

@PostConstruct and transactions

Another common mistake: @Transactional on a @PostConstruct method. It doesn't work — @PostConstruct runs before Spring wraps the bean in a proxy.

// doesn't work
@PostConstruct
@Transactional
public void init() { ... }

// works — this method will be called when Spring is already fully ready
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initData() { ... }

Kafka, HTTP and other external resources

@Transactional manages only the database. Kafka, HTTP requests, writes to Redis are not part of it: if the transaction rolls back, a message already sent to Kafka won't come back:

// don't do this: if the TX rolls back, the message is already gone
@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
    var order = orderRepo.save(...);
    kafkaTemplate.send("orders.created", order);   // can't be undone on rollback
    return order.id();
}

The Outbox pattern solves it: the message goes into the same database in the same transaction, and a separate process publishes it later:

// do this
@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
    var order = orderRepo.save(...);
    outboxRepo.save(new OutboxEvent("OrderCreated", order.id(), payload));
    return order.id();
    // if the TX rolls back — both inserts roll back together
}

Long transactions

A transaction holds a database connection. If long external calls (HTTP, third-party APIs) happen inside it, the connection is busy the whole time:

// bad: the connection is busy for 5+ seconds
@Transactional
public void processOrder(OrderId id) {
    var order = orderRepo.find(id);
    paymentGateway.charge(order.total());      // HTTP, ~3 sec
    deliveryService.scheduleDelivery(order);   // HTTP, ~2 sec
    order.confirm();
    orderRepo.save(order);
}

The right approach — external calls outside the transaction, and database changes in short, separate transactions:

// orchestrator without @Transactional
public void processOrder(OrderId id) {
    var order = orderStorage.load(id);              // short TX, the connection is released
    paymentGateway.charge(order.total());           // outside the TX
    deliveryService.scheduleDelivery(order);        // outside the TX
    orderStorage.confirm(id);                       // short TX
}
// separate bean — the call goes through the proxy
@Component
class OrderStorage {

    @Transactional
    public Order load(OrderId id) { return orderRepo.find(id); }

    @Transactional
    public void confirm(OrderId id) {
        var order = orderRepo.find(id);
        order.confirm();
        orderRepo.save(order);
    }
}

The short methods live in a separate class for a reason: inside the same OrderService the load(id) call would go past the proxy — the self-invocation trap again. Don't want a second bean? TransactionTemplate opens the transaction explicitly, with no proxy at all.

A transaction should live for seconds, not minutes.

In short

  • Everything rests on the proxy: it works only on calls from outside the class, stays silent on private, and a call through this bypasses it — fix with a separate bean or TransactionTemplate.
  • The annotation belongs on the service layer, not on the controller (HTTP cycle) and not on the repository (it won't combine operations).
  • REQUIRED almost always; REQUIRES_NEW takes a second connection, NESTED places a savepoint and needs DataSourceTransactionManager.
  • Only RuntimeException and Error roll back by default — a checked exception commits; fix with rollbackFor or a runtime wrapper.
  • readOnly = true on reading methods: writes are refused, dirty checking is off, the connection may go to a replica.
  • A transaction leaves neither its thread nor its database: after commit — @TransactionalEventListener, for Kafka — Outbox; external calls stay outside, so it lives for seconds.