DEV Community

Dominik Paszek
Dominik Paszek

Posted on

Spring Boot @Transactional: 5 Bugs That Are Probably in Your Production Code

Spring Boot @Transactional: 5 Bugs That Are Probably in Your Production Code

You deploy a new feature. Tests pass. Code review done. Production looks normal.

A week later someone calls. Money is missing. The transfer left one account and never arrived at the other. No error in the logs. No exception. The transaction just... didn't work.

These aren't exotic edge cases. They're the five most common @Transactional bugs, and they all share one property: they fail silently. No warning, no stack trace, just wrong data in your database.


Why these bugs are hard to find: the proxy model

Before the bugs, you need one piece of context.

@Transactional doesn't work on your object. It works on a proxy that wraps it. When Spring creates a @Transactional bean, it builds a CGLIB subclass that intercepts incoming method calls and wraps them in transaction management. Calls that go through the proxy get a transaction. Calls that bypass the proxy don't.

Every bug below is a different way of bypassing the proxy.


Bug #1 — Self-invocation

The most Googled Spring problem. People spend five hours on this because there's zero runtime feedback.

// ❌ BUG
@Service
public class OrderService {

    public void placeOrder(Order order) {
        validateOrder(order);
        saveOrder(order);  // this.saveOrder() — bypasses the proxy
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
        notificationRepository.save(new Notification(order));
        // RuntimeException here → NO rollback
        // the first save is already committed
    }
}
Enter fullscreen mode Exit fullscreen mode

placeOrder calls saveOrder via this. The proxy never intercepts it. The @Transactional annotation is decorative.

Two fixes. The architecturally clean one is to extract saveOrder into a separate bean:

// ✅ FIX 1 — separate bean
@Service
public class OrderPersistenceService {
    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
        notificationRepository.save(new Notification(order));
    }
}
Enter fullscreen mode Exit fullscreen mode

The faster workaround when a refactor isn't realistic:

// ✅ FIX 2 — self-injection
@Service
public class OrderService {
    private final OrderService self;

    public OrderService(@Lazy OrderService self, ...) { this.self = self; }

    public void placeOrder(Order order) {
        self.saveOrder(order); // via proxy ✓
    }
}
Enter fullscreen mode Exit fullscreen mode

IntelliJ flags self-invocation with a yellow underline through SpringTransactionalMethodCallsInspection. If you've disabled it, turn it back on.


Bug #2 — Checked exceptions commit

Spring rolls back on RuntimeException and Error. On a checked exception, it commits. Always.

This is intentional — inherited from EJB, where checked exceptions meant "expected business event, continue." The logic falls apart when you're throwing IOException halfway through a money transfer.

// ❌ BUG — money debited, never credited
@Transactional
public void transferMoney(Long fromId, Long toId, BigDecimal amount)
        throws IOException {

    Account from = accountRepository.findById(fromId).orElseThrow();
    from.debit(amount);
    accountRepository.save(from);  // persisted

    callComplianceApi(from);       // throws IOException → COMMIT
}
Enter fullscreen mode Exit fullscreen mode
// ✅ FIX
@Transactional(rollbackFor = Exception.class)
public void transferMoney(...) throws IOException { ... }
Enter fullscreen mode Exit fullscreen mode

If you're on Spring Boot 3.2+, there's a global option that changes the default for your entire application:

@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
Enter fullscreen mode Exit fullscreen mode

One config change, eliminates this class of bug everywhere.

There's a related trap worth naming: swallowing exceptions inside @Transactional. If you catch and log without rethrowing, the transaction manager never sees the exception and commits. No indication anything went wrong.


Bug #3 — Private and final methods

CGLIB creates a proxy by subclassing your bean. Private and final methods can't be overridden — that's a Java rule, not a Spring one. The annotation is silently ignored.

// ❌ BUG
@Service
public class PaymentService {

    @Transactional  // does nothing
    private void persistPayment(Payment p) {
        paymentRepository.save(p);
        throw new RuntimeException("fail");
        // NO rollback — no transaction was ever started
    }
}
Enter fullscreen mode Exit fullscreen mode

Fix: make it public. Spring 6.0 fixed this for protected and package-private methods. private and final will never work.


Bug #4 — REQUIRES_NEW deadlock

This one works perfectly in development and kills production under load, with no exception pointing to the cause.

REQUIRES_NEW doesn't create a nested transaction — it suspends the current database connection and opens a new, independent one from the pool. HikariCP defaults to 10 connections.

Under load, 10 concurrent requests each grab a connection for their outer transaction — pool is full. Each then calls auditService which needs a second connection for REQUIRES_NEW. No connections left. Every thread waits forever. No exception. Application freezes.

// ❌ BUG
@Transactional  // holds conn #1
public void createOrder(Order order) {
    orderRepository.save(order);
    auditService.logAudit("Order: " + order.getId());
    // needs conn #2 → pool exhausted → DEADLOCK
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAudit(String message) {
    auditRepository.save(new AuditLog(message));
}
Enter fullscreen mode Exit fullscreen mode

Audit doesn't need to be inside the main transaction — it just needs to run after the order commits.

// ✅ FIX
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);
    eventPublisher.publishEvent(new OrderCreatedEvent(order.getId()));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
// safe — outer TX is already gone, no connection held
public void handle(OrderCreatedEvent event) {
    auditRepository.save(new AuditLog("Order: " + event.getOrderId()));
}
Enter fullscreen mode Exit fullscreen mode

REQUIRES_NEW is only safe when the outer transaction no longer exists — exactly what AFTER_COMMIT guarantees.


Bug #5 — readOnly=true silently drops writes

readOnly = true is a hint, not a constraint. Hibernate responds by disabling dirty checking and entity snapshots — a real performance improvement for reads, up to 50% in some cases. But it also means modified entities are never flushed. No exception, no warning.

// ❌ BUG
@Service
@Transactional(readOnly = true)
public class ProductService {

    public void updatePrice(Long id, BigDecimal price) {
        Product p = productRepository.findById(id).orElseThrow();
        p.setPrice(price);
        // Hibernate does NOT flush — change is silently lost
    }
}
Enter fullscreen mode Exit fullscreen mode

The fix: annotate the class with readOnly = true as the default, then override each write method with plain @Transactional:

// ✅ FIX
@Service
@Transactional(readOnly = true)
public class ProductService {

    @Transactional  // overrides to readOnly = false
    public void updatePrice(Long id, BigDecimal price) {
        Product p = productRepository.findById(id).orElseThrow();
        p.setPrice(price); // dirty checking active, flushed at commit ✓
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: MySQL enforces readOnly at the DB level and will throw an error on writes. PostgreSQL doesn't enforce it — writes may silently succeed. Don't rely on the database to catch this.


The pattern behind all five

Every bug above is a version of the same thing: something bypasses the proxy. Self-invocation goes through this. Private methods can't be intercepted. REQUIRES_NEW opens a connection outside the current transaction context. If @Transactional isn't working, the first question is always: are you bypassing the proxy?


Cheat sheet

Bug Symptom Fix
Self-invocation Annotation present, no transaction Separate bean or @Lazy self-injection
Checked exceptions Rollback expected, commit happens rollbackFor = Exception.class or RollbackOn.ALL_EXCEPTIONS
Private/final methods Annotation silently ignored, zero feedback Make it public
REQUIRES_NEW deadlock App freezes under load, no exception @TransactionalEventListener(AFTER_COMMIT)
readOnly trap Entity changes silently lost @Transactional override on write methods

Video walkthrough with live demos: [https://youtu.be/XOR-mmipVlU]

Source code: [https://gitlab.com/PaszekDevv/transactional]

Next: Hexagonal Architecture in Spring Boot — how to structure your code so that business logic tests run in 50ms instead of 45 seconds.

Top comments (0)