Why I Stopped Using @Transactional on Everything (And What I Do Instead)
If you've worked in a Spring Boot codebase for more than a few weeks, you've
probably seen it — @Transactional sprinkled across service methods
like a protective charm. Put it everywhere, the thinking goes, and your data
will be safe.
I used to do this too. Then I spent a week debugging a production incident that came down to a misunderstanding of how Spring's proxy-based AOP actually works. This post is what I wish I'd read before that week.
We'll go from the JDBC fundamentals all the way through propagation, rollback rules,
the @Async trap, and debugging — with the goal of turning
@Transactional from a magic annotation into a precise tool.
It's All Just JDBC
Before touching Spring at all, it helps to understand what a transaction actually is at the wire level. Everything Spring does maps back to four lines of JDBC:
Connection connection = dataSource.getConnection();
try (connection) {
connection.setAutoCommit(false); // begin
// ... your SQL ...
connection.commit(); // success
} catch (SQLException e) {
connection.rollback(); // failure
}
That's it. setAutoCommit(false) starts the transaction.
commit() or rollback() ends it. When you use
@Transactional, Spring generates this code for you — but it's
always this underneath. Isolation levels map to
connection.setTransactionIsolation(). Nested transactions map to
connection.setSavepoint(). There's no magic, just generated boilerplate.
Keeping this mental model in mind makes every Spring transaction behaviour obvious rather than surprising.
How @Transactional Works: Proxies
Spring can't rewrite your bytecode on the fly (unless you use AspectJ weaving,
which most projects don't). Instead, it wraps your bean in a proxy. When you
call a @Transactional method, you're calling the proxy, which opens
a connection, delegates to your real method, then commits or rolls back.
// What you write:
@Service
public class UserService {
@Transactional
public void registerUser(User user) {
userRepository.save(user);
}
}
// What Spring generates around it (conceptually):
public void registerUser(User user) {
connection.setAutoCommit(false);
try {
realUserService.registerUser(user);
connection.commit();
} catch (RuntimeException e) {
connection.rollback();
throw e;
}
}
The proxy is the key. Everything else — propagation, rollback rules, isolation — only works because of it. And as we'll see, that's exactly where most bugs hide.
Physical vs Logical Transactions
Spring distinguishes between physical transactions (actual JDBC connections
with autoCommit=false) and logical transactions
(@Transactional-annotated method calls in your code).
Multiple logical transactions can share a single physical one. This is the default when a transactional method calls another transactional method — one real database transaction, two logical boundaries. Understanding this distinction makes propagation intuitive rather than confusing.
Propagation: What Actually Happens
Propagation defines what Spring does at the JDBC level when a
@Transactional method is called from inside another
@Transactional method. There are seven modes, but two cover 95%
of real use cases.
REQUIRED (default) — join the existing transaction if one is open,
otherwise start a new one. One getConnection() → setAutoCommit(false) → commit()
cycle. Right choice for the vast majority of service methods.
REQUIRES_NEW — always start a fresh, independent transaction.
Maps to a second getConnection() → setAutoCommit(false) → commit().
The original transaction is suspended until this one finishes. Classic use case:
audit logs or technical events that must persist even if the main transaction rolls back.
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
auditService.log(order); // REQUIRES_NEW — persists even on rollback
throw new PaymentFailedException(); // order rolled back, audit log stays
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Order order) {
auditRepository.save(new AuditEntry(order));
}
NESTED — creates a savepoint inside the current transaction via
connection.setSavepoint(). If the nested part fails, only the work since
the savepoint rolls back — the outer transaction can still commit. Support depends
on your database driver.
The remaining modes — MANDATORY, SUPPORTS,
NOT_SUPPORTED, NEVER — control whether Spring throws an
exception based on whether a transaction already exists. Worth knowing they exist;
rarely worth reaching for them in typical applications.
The Pitfalls
1. Self-invocation silently drops transaction boundaries
If a method calls another method in the same bean, the call never goes through the proxy. Spring never sees it. No transaction is opened, no propagation happens — silently.
@Service
public class InvoiceService {
@Transactional
public void generateInvoice(InvoiceRequest req) {
attachPdf(req); // ⚠️ internal call — proxy bypassed entirely
invoiceRepository.save(req.toInvoice());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void attachPdf(InvoiceRequest req) {
// You expect a new transaction. You get nothing.
pdfRepository.save(req.toPdf());
}
}
Fix: move attachPdf into a separate Spring bean so the call goes
through the proxy.
2. @Transactional on private methods
Spring proxies intercept public method calls only. Annotating a private method does nothing — no exception, no warning, silently ignored.
@Transactional // ❌ ignored — method is private
private void chargeCard(Payment p) {
paymentRepo.save(p);
}
Transactional methods must be public. Always.
3. Checked exceptions don't trigger rollback by default
Spring's default rollback policy triggers only on RuntimeException
and Error. A checked exception commits the transaction — even if
your data is in an inconsistent state.
@Transactional
public void transferFunds(Transfer t) throws InsufficientFundsException {
debit(t.from(), t.amount());
credit(t.to(), t.amount()); // throws InsufficientFundsException
// ⚠️ debit committed. Account is now overdrawn.
}
// Fix option 1: be explicit
@Transactional(rollbackFor = InsufficientFundsException.class)
public void transferFunds(Transfer t) throws InsufficientFundsException { ... }
// Fix option 2: model failures as unchecked exceptions
public class InsufficientFundsException extends RuntimeException { }
Extending RuntimeException is the cleaner long-term approach — the
transaction rolls back automatically and the intent is explicit.
4. Swallowed exceptions prevent rollback
Spring rolls back only if the exception escapes the transactional boundary. Catch it inside the method and the transaction commits normally.
@Transactional
public void sendWelcomeEmail(User user) {
userRepository.save(user);
try {
emailService.send(user.getEmail());
} catch (EmailException e) {
log.error("Email failed", e);
// ⚠️ exception swallowed — transaction commits, user saved anyway
}
}
Either rethrow, or mark the transaction for rollback explicitly:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
5. Long-running transactions
Transactions hold database locks for their entire duration. Doing I/O, calling external services, or sending emails inside a transaction holds those locks the whole time. Under load this causes contention, timeouts, and deadlocks.
Keep transactions focused on database work. The timeout attribute
is a useful safety net:
@Transactional(timeout = 5) // rolls back if not done in 5 seconds
public void generateReport(ReportRequest req) { ... }
@Async + @Transactional: A Common Trap
Transactions are thread-bound. A transaction lives and dies on the thread that started
it. @Async executes the method on a different thread. These two facts
together mean an async method never participates in the caller's transaction.
@Transactional
public void registerUser(User user) {
userRepository.save(user);
notificationService.sendWelcome(user); // @Async — different thread, no transaction
throw new RuntimeException("Validation failed");
}
Result: user save rolled back, welcome notification sent anyway. Inconsistency with no error and no warning.
The right pattern is @TransactionalEventListener. Publish an event
inside the transaction; handle the side effect only after commit:
@Transactional
public void registerUser(User user) {
userRepository.save(user);
eventPublisher.publishEvent(new UserRegisteredEvent(user));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserRegistered(UserRegisteredEvent event) {
notificationService.sendWelcome(event.getUser()); // only runs after commit
}
This guarantees side effects only happen when the database is actually in the expected state. One of the most useful patterns in the Spring ecosystem.
Debugging Transactions
Transactional bugs tend to be silent — no stack traces, just data in the wrong state. Turn on transaction logging:
# application.yml
logging:
level:
org.springframework.transaction: DEBUG
org.hibernate.SQL: DEBUG
You'll see exactly when transactions open, which methods own them, and when they commit or roll back:
Creating new transaction with name [UserService.registerUser]
Participating in existing transaction
Rolling back JDBC transaction on Connection
Committing JDBC transaction on Connection
When you hit UnexpectedRollbackException: Transaction silently rolled back,
look for a setRollbackOnly event earlier in the logs — not at the commit site.
The rollback is always set before the exception surfaces.
One more thing worth knowing: a log.info("User registered") inside a
transactional method might appear even if the transaction later rolls back. For logs
that reflect what actually committed, use @TransactionalEventListener
with AFTER_COMMIT.
What I Do Instead
One transaction per business operation
The transactional boundary should map to one meaningful business action — place an order, transfer funds, register a user. If a single transaction is touching five different aggregates, that's a design smell. Coordinate across aggregates using domain events and eventual consistency, not a shared transaction.
Be explicit before adding @Transactional
Ask: what am I protecting here? Which operations need to succeed or fail together?
If you can't answer clearly, the boundary is probably wrong.
Use readOnly = true on query-only methods — it signals intent and gives
the persistence layer room to optimise.
Keep transactions short
Open a transaction, do database work, close it. Don't call external services, send emails, or make HTTP calls inside a transaction. Finish the database work, then trigger side effects via events or async processing after commit.
Quick Reference
- Self-calls bypass the proxy — move the method to another bean
- Private methods are ignored —
@Transactionalrequirespublic - Checked exceptions don't roll back by default — use
rollbackForor extendRuntimeException - Swallowed exceptions prevent rollback — rethrow or call
setRollbackOnly() REQUIRES_NEWonly works through the proxy, not self-calls@Async= different thread = no shared transaction — use@TransactionalEventListener- Long transactions hold locks — keep them short, push side effects after commit
- Enable
org.springframework.transaction: DEBUGwhen debugging readOnly = trueon query methods — signals intent, allows DB/ORM optimisations
Transaction management is one of those areas where Spring does a lot of work to make things feel simple — which is exactly why it's easy to get wrong. The annotation is a tool, not a guarantee. Once you understand the proxy model and the JDBC underneath, the behaviour stops feeling magical and starts feeling obvious.
If you found this useful or have a production horror story of your own, feel free to reach out at longin.koziolkiewicz@gmail.com.