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)
protectedand package-private methods do work, butprivatenever will — keep transactional methodspublic.
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.
| Mode | Behavior |
|---|---|
REQUIRED (default) | If there's a transaction — join it. If not — open a new one. |
REQUIRES_NEW | Suspend the current transaction and open a new, separate one. |
NESTED | Create a savepoint inside the current transaction. |
MANDATORY | A 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.
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:
- JDBC sends
SET TRANSACTION READ ONLY— a write inside such a transaction fails, so an accidentalUPDATEin a reporting method shows up at once. - Hibernate (JPA) disables dirty checking and flush — a little less work for the CPU.
- 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 throughthisbypasses it — fix with a separate bean orTransactionTemplate. - The annotation belongs on the service layer, not on the controller (HTTP cycle) and not on the repository (it won't combine operations).
REQUIREDalmost always;REQUIRES_NEWtakes a second connection,NESTEDplaces a savepoint and needsDataSourceTransactionManager.- Only
RuntimeExceptionandErrorroll back by default — a checked exception commits; fix withrollbackForor a runtime wrapper. readOnly = trueon 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.
What to read next
- Transaction isolation levels in PostgreSQL — READ COMMITTED, REPEATABLE READ, SERIALIZABLE and anomalies.
- Locks in PostgreSQL — SELECT FOR UPDATE and other kinds of locks.
- Connection pooling — HikariCP and PgBouncer — why REQUIRES_NEW is dangerous under high load.
- Spring AOP — how the proxy is built under the hood.