You put @Transactional on a method — and it feels like everything should just work on its own. But sometimes the transaction doesn't roll back on error, or it opens where you didn't expect it. Let's look at how it's built and where it usually breaks.
How @Transactional works at all
Spring doesn't magically "see" the annotation at runtime. Instead, it wraps your class in a proxy — a special wrapper object. When someone from the outside calls a method with @Transactional, the proxy intercepts the call, opens a transaction, calls the original method, and then commits or rolls back the transaction.
Calling code
↓
Proxy (opens TX)
↓
Your method
↓
Proxy (commit or rollback)
All the limitations that seem strange until you know about this mechanism follow from here.
Where to put @Transactional
The annotation should live on methods of the service layer — where a single 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();
}
}
Here the transaction guarantees: either the order and the event in the outbox are saved — or nothing. That's exactly what you need.
Common placement mistakes:
- On the controller — an HTTP request and a business operation are different things. The transaction should bound the business action, not the HTTP cycle.
- On the repository — redundant. Spring Data already has transactions at the level of individual methods. A transaction on the repository won't combine several operations into one atomic unit — for that it has to sit higher.
- On a private method — Spring AOP doesn't see private methods. The annotation will be silently ignored. The method must be
public.
The main trap: self-invocation
This is the most common reason why @Transactional "doesn't work". If a method calls another method of the same class through this, the proxy doesn't participate — 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 is to move the method into a separate bean. Then the call will go 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 determines what to do if a transaction is already open when a method with @Transactional 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. |
In most cases you need REQUIRED. The rest are for specific scenarios.
REQUIRES_NEW — when you need a separate transaction
A typical example: an audit log. The log entry must be saved 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) { ... }
}
An important detail: REQUIRES_NEW takes a separate connection from the pool. The current connection stays busy meanwhile. Under high load this doubles the connection pool usage — keep that in mind.
NESTED — rolling back part of the operation
If you need 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());
}
}
}
@Transactional(propagation = Propagation.NESTED)
public void saveItem(Item item) { ... }
Rolling back the savepoint on error leaves the outer transaction alive — the remaining items keep being processed.
readOnly = true
@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }
The readOnly flag has three effects:
- JDBC sends the database
SET TRANSACTION READ ONLY— PostgreSQL doesn't create a snapshot for write operations. - Hibernate (JPA) disables dirty checking and flush — a little less work for the CPU.
- If a routing DataSource with a read replica is configured — the connection is routed there automatically.
Put readOnly = true on all methods that only read data.
Why checked exceptions don't roll back the transaction
This is a historical decision in Spring: by default the transaction rolls back only on RuntimeException and Error. Checked exceptions (IOException, SQLException and others) do not roll back the transaction — it will commit.
@Transactional
public void doWork() throws IOException {
repository.save(...);
if (someCondition) throw new IOException(); // the transaction will commit, not roll back!
}
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 expresses the contract more clearly: a method without checked exceptions in the signature, errors are runtime, behavior is predictable.
Transactions and async
A transaction is not passed to another thread. If a method starts an asynchronous operation, it will run without a transaction:
@Transactional
public void mainOp() {
repository.save(...);
doSomethingAsync(); // will run in another thread, WITHOUT a transaction
}
@Async
@Transactional
public CompletableFuture<Void> doSomethingAsync() {
// this will open ITS OWN new transaction, not the parent one
}
If you need to do something after a transaction commits successfully, use @TransactionalEventListener:
@Component
class OrderEventHandler {
@TransactionalEventListener // AFTER_COMMIT by default
public void onOrderCreated(OrderCreated event) {
emailService.send(...);
}
}
The listener is called only if the transaction committed — with no risk of sending an email on rollback.
@PostConstruct and transactions
Another common mistake: putting @Transactional on a @PostConstruct method. This doesn't work — @PostConstruct is called before Spring has a chance to wrap 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. Sending to Kafka, HTTP requests, writing to Redis — are not part of the transaction. If the transaction rolls back, a message already sent to Kafka won't go anywhere:
// dangerous: 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 solution is the Outbox pattern: the message is saved to the same database in the same transaction, and a separate process publishes it later:
@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 connection to the database. If long external calls (HTTP, third-party APIs) happen inside the transaction, 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:
public void processOrder(OrderId id) {
var order = loadOrder(id); // short TX, the connection is released
paymentGateway.charge(order.total()); // outside the TX
deliveryService.scheduleDelivery(order); // outside the TX
confirmOrder(id); // short TX
}
@Transactional
Order loadOrder(OrderId id) { return orderRepo.find(id); }
@Transactional
void confirmOrder(OrderId id) {
var order = orderRepo.find(id);
order.confirm();
orderRepo.save(order);
}
A transaction should live for seconds, not minutes.
In short
@Transactionalworks through a proxy — only onpublicmethods, only when called from outside the class.- Self-invocation (a call through
this) bypasses the proxy — the transaction doesn't open. The solution is a separate bean. - Put it on service-layer methods, not on controllers and not on repositories.
REQUIRED— the default and almost always the right choice.REQUIRES_NEWtakes a separate connection from the pool.- Checked exceptions don't roll back the transaction — wrap them in a RuntimeException or specify
rollbackFor. readOnly = true— on all methods that only read data.- A transaction is not passed to another thread. For actions after commit —
@TransactionalEventListener. - Kafka, HTTP, Redis — outside the transaction. For atomicity with Kafka — the Outbox pattern.
- A transaction should be short: external calls belong outside it.
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.