← Back to list

The Spring Boot Transaction That Lied to Me

@Transactional looks safe. It isn’t. Here’s the exact failure I debugged at 2AM and what the docs don’t say

ProdRescue By Devrim in Stackademic · 2026-05-29 20:08 · 0 claps · 4.2 min read
#spring-boot #java #java8 #coding #programming
Open on Medium ↗
Wiki topics: 💻 · Programming

The Spring Boot Transaction That Lied to Me

@Transactional looks safe. It isn’t. Here’s the exact failure I debugged at 2AM and what the docs don’t say

There’s a particular kind of confidence that comes with @Transactional. You put it on a method, you know the database will roll back if something goes wrong, and you move on. The annotation feels like a guarantee.

It isn’t. Not always. And the ways it breaks are subtle enough that you can have a bug in production for days before you realize the rollback you were counting on never happened.

I learned this the hard way. Most people do.

The bug that shouldn’t have existed

We had a service method that created an order and then called an external payment processor. If the payment failed, we expected everything to roll back — no orphaned order in the database, clean state, customer sees an error and tries again.

The method was annotated with @Transactional. Tests passed. Code review was clean. It went to production.

Three days later someone noticed orphaned orders in the database. Orders that had been created but never paid for, not because of legitimate cancellations but because the payment processor was throwing exceptions during high traffic and the orders weren’t rolling back.

The annotation was there. The rollback wasn’t happening. Here’s why.

Self-invocation kills the proxy

Spring’s @Transactional works through a proxy. When another class calls your annotated method, Spring wraps it, starts a transaction, and manages the commit or rollback. This is the normal case and it works exactly as you'd expect.

What nobody warns you about clearly enough: when a method calls another @Transactional method within the same class, it bypasses the proxy entirely. It's a direct method call. No transaction magic. The inner method runs without its own transaction context, and if the outer method's transaction rules weren't set up to cover it, you're exposed.

@Service
public class OrderService {

    @Transactional
    public void processOrder(Order order) {
        saveOrder(order);      // direct call - proxy bypassed
        chargePayment(order);
    }
    @Transactional
    public void saveOrder(Order order) {
        // this @Transactional does nothing here
        orderRepository.save(order);
    }
}

The @Transactional on saveOrder is invisible from inside the same class. You think you have two transaction boundaries. You have one. And that one may not behave the way you expect when something throws.

The fix is either to move the inner method to a different bean (so the proxy kicks in properly), or restructure the code so the single outer transaction covers everything intentionally.

Checked exceptions don’t roll back by default

This one gets people constantly and it’s right there in the documentation but it’s easy to miss if you’re coming from a background where exceptions are exceptions.

Spring’s default behavior is to roll back on unchecked exceptions — anything that extends RuntimeException. Checked exceptions, the ones you declare in your method signature, do not trigger a rollback by default. The transaction commits even if you caught and re-threw a checked exception. Even if you let it propagate.

@Transactional
public void createOrder(Order order) throws PaymentException {
    orderRepository.save(order);
    paymentService.charge(order); // throws PaymentException (checked)
    // transaction COMMITS even though this threw
}

The fix is explicit:

@Transactional(rollbackFor = PaymentException.class)
public void createOrder(Order order) throws PaymentException {
    orderRepository.save(order);
    paymentService.charge(order);
}

Or if you want all exceptions to trigger rollback: rollbackFor = Exception.class. I've started making this explicit on any method that deals with checked exceptions rather than relying on defaults. The default behavior made sense as a design decision decades ago. It still surprises people today.

Transaction propagation and why REQUIRES_NEW is dangerous

When you have a transaction already running and you call another @Transactional method, the default propagation is REQUIRED — join the existing transaction. Usually that's fine.

The problem comes when someone uses REQUIRES_NEW to force a new transaction, often because they want part of the work to commit independently. Audit logs are a common example. You want the audit entry to survive even if the outer operation rolls back.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(String event) {
    auditRepository.save(new AuditEntry(event));
}

What people don’t realize is that REQUIRES_NEW suspends the outer transaction. While the inner transaction is running, the outer one is paused. This means the outer transaction is holding its database connections but not using them — they're just sitting there. Under load, with a connection pool that isn't sized generously, this can exhaust your pool. Requests start queuing for connections. Everything slows down or stops.

I’ve seen this cause an incident that looked at first like a database problem. Connection pool metrics were maxed out, queries were timing out, the database itself was fine. The culprit was REQUIRES_NEW being called in a hot path that nobody had stress tested.

If you’re using REQUIRES_NEW, know where it's called and how frequently. It's not something to reach for casually.

How to actually verify your transactions are working

Don’t trust the annotation. Verify.

The quickest way to check if your transaction is active at any point in the code:

boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
System.out.println("Transaction active: " + isActive);

Drop this in during debugging when something feels wrong. It tells you immediately whether you’re inside a real transaction or not.

For more visibility in development, set this in your application.properties:

logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.orm.jpa=DEBUG

This logs every transaction start, commit, rollback, and participation event. It’s noisy for production but invaluable when you’re debugging transactional behavior in development. Run through your flow once, read the logs, and you’ll see exactly what’s happening — whether transactions are being created, joined, or silently bypassed.

The mental model that actually helps

I stopped thinking of @Transactional as "this is safe" and started thinking of it as "this is where I declared my intent, now let me verify the behavior matches."

The annotation is a starting point. The proxy mechanism, the propagation behavior, the rollback rules — all of these are configurable and all of them have defaults that exist for historical reasons, not because they match your intuition. Understanding what’s actually happening under the annotation is what separates code that works in testing from code that works in production.

If you want to go deeper on this — the propagation modes, the isolation levels, the patterns that hold up under real production load — I wrote it all out here: @Transactional Is Lying To You.

More on Medium and longer reads on Substack.


메타데이터
post_id
7cdb2badfdaf
slug
the-spring-boot-transaction-that-lied-to-me-7cdb2badfdaf
url
https://medium.com/@coding_with_tech/the-spring-boot-transaction-that-lied-to-me-7cdb2badfdaf
canonical_url
https://medium.com/@coding_with_tech/the-spring-boot-transaction-that-lied-to-me-7cdb2badfdaf
author_url
https://medium.com/@coding_with_tech
status
ok
fetched_at
2026-06-09 15:37:30