Skip to content
Back to blog
6 min read

Six Ways @Transactional Quietly Does Nothing

Spring BootJPATransactionsHibernate

A sale failed halfway through. The stock was decremented, the sale row was never written, and nothing rolled back. The method had @Transactional on it. I checked twice.

@Transactional is one word, which makes it feel like it can't go wrong. It can, in at least six ways, and every one of them is silent — no exception, no warning, just a transaction that wasn't there.

First, what the annotation actually does

Spring doesn't rewrite your method. It wraps your bean in a proxy — a generated object that looks like your class, sits in front of it, and does the transaction bookkeeping before and after delegating to the real instance.

caller → proxy (begin tx) → your bean (your code) → proxy (commit or rollback)

Almost every trap below is a consequence of that one sentence. If a call doesn't pass through the proxy, there is no transaction.

Trap 1: calling the method from inside the same class

This is the one that gets everybody.

@Service
public class SaleService {

    public void recordDailySales(List<SaleRequest> requests) {
        for (SaleRequest r : requests) {
            recordSale(r); // no transaction here
        }
    }

    @Transactional
    public void recordSale(SaleRequest request) {
        // ...
    }
}

recordSale(r) is really this.recordSale(r). this is the raw bean, not the proxy. The call never leaves the object, so the proxy never sees it, so no transaction starts. The annotation is decoration.

The fix is to make the call cross a bean boundary:

@Service
public class SaleService {

    private final SaleRecorder recorder; // separate bean

    public void recordDailySales(List<SaleRequest> requests) {
        requests.forEach(recorder::recordSale); // through the proxy
    }
}

You can also inject the bean into itself and call self.recordSale(r), and it works, but it's a signal that the method belongs somewhere else. When I want a transaction inside a method without moving code, I use TransactionTemplate instead — it's explicit and there's no proxy to fool:

transactionTemplate.execute(status -> {
    // this really is in a transaction
    return null;
});

Trap 2: checked exceptions don't roll back

Spring's default is to roll back on RuntimeException and Error. Checked exceptions commit.

@Transactional
public void recordSale(SaleRequest request) throws InsufficientStockException {
    stockService.decrement(request);          // succeeds
    throw new InsufficientStockException();   // checked → commits anyway
}

The stock decrement is now permanent. This rule comes from an old EJB convention where checked exceptions meant "expected business outcome" and unchecked meant "something broke". It is not what most people assume.

Two fixes. Either be explicit:

@Transactional(rollbackFor = Exception.class)

Or make your business exceptions extend RuntimeException, which is what I do — if it's serious enough to abort the operation, it's serious enough to be unchecked.

Trap 3: catching the exception yourself

@Transactional
public void recordSale(SaleRequest request) {
    try {
        stockService.decrement(request);
        saleRepository.save(toSale(request));
    } catch (Exception e) {
        log.error("Sale failed", e); // and then... commit
    }
}

The proxy decides to roll back by watching what comes out of the method. Catch the exception and nothing comes out, so it commits whatever partial work happened before the failure. Logging an error and committing the mess is worse than crashing.

There's a nastier version. If an inner @Transactional method throws, it marks the shared transaction rollback-only before the exception reaches you. You catch it, you carry on, and then the outer commit fails with:

UnexpectedRollbackException: Transaction silently rolled back
because it has been marked as rollback-only

That message confuses people because the stack trace points at the commit, not at the inner method that actually failed twenty lines earlier. If you must catch and continue past a failing sub-operation, that sub-operation needs its own transaction:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditEntry(AuditEntry entry) { ... }

REQUIRES_NEW suspends the caller's transaction and runs on a separate connection, so its failure — or its success — is independent. That's exactly what you want for audit logs and notifications, which should survive a business rollback.

Trap 4: private, final, and static methods

The proxy works by overriding your method. It cannot override what it cannot see:

@Transactional
private void recordSale(...) { }   // ignored, silently

@Transactional
public final void recordSale(...) { }  // ignored with CGLIB proxies

No startup error in the private case — the annotation just does nothing. Keep transactional methods public and non-final.

Trap 5: the transaction ends before your response is written

The transaction closes when the method returns. If you return an entity with lazy associations and let Jackson serialize it afterwards, the session may already be gone:

LazyInitializationException: could not initialize proxy - no Session

The common "fix" is Open Session In View, which Spring Boot enables by default (spring.jpa.open-in-view=true) and warns about at startup. It keeps the Hibernate session open for the whole request, which makes the error disappear and replaces it with a worse problem: your view layer now triggers database queries one lazy field at a time, holding a connection for the entire request. It's N+1 by architecture.

I turn it off and load what I need inside the boundary:

spring:
  jpa:
    open-in-view: false

Then map to a DTO inside the transactional method. The exception you get after switching it off isn't a regression — it's the honest version of a bug you already had.

Trap 6: readOnly is not a comment

@Transactional(readOnly = true)
public List<ProductView> listProducts() { ... }

This does real work. Hibernate sets the flush mode to manual, so it stops dirty-checking every loaded entity at the end — less memory and less CPU on big result sets, and no accidental UPDATE because something touched a setter. Spring also marks the JDBC connection read-only, which some setups use to route queries to a replica.

Put it on every read path. It's free.

How I check that a transaction is real

Two things, both cheap. Turn on the log:

logging.level.org.springframework.transaction.interceptor: TRACE

You get one line per boundary — Getting transaction for [...recordSale]. If the line isn't there, the annotation isn't working, and you've just found trap 1 in ten seconds.

Then write the test that actually proves it. Not "does the happy path save", but "does a mid-method failure leave nothing behind":

@Test
void failedSaleLeavesStockUntouched() {
    int before = stockRepository.findById(itemId).orElseThrow().getQuantity();

    assertThatThrownBy(() -> saleService.recordSale(failingRequest))
        .isInstanceOf(InsufficientStockException.class);

    assertThat(stockRepository.findById(itemId).orElseThrow().getQuantity())
        .isEqualTo(before);
}

That test fails against every trap on this list, which is the whole point of writing it.

The summary I'd give in an interview

@Transactional is implemented with a proxy, and everything surprising about it follows from that. A call from inside the same class never reaches the proxy, so there's no transaction. Rollback is triggered by unchecked exceptions only, so a checked exception commits and a caught exception commits. Private and final methods can't be proxied at all. And the transaction ends when the method returns, not when the response is sent — so map to DTOs inside the boundary and leave Open Session In View off.

ShareLinkedInPost

Have a question about this?

Get in touch