Why @Transactional Often Fails: 13 Real‑World Pitfalls from 6 Years of Experience

This article analyses thirteen common mistakes that cause Spring's @Transactional annotation to be ineffective or not roll back, covering unnecessary usage, private/final/static methods, self‑invocation, wrong propagation settings, async threads, unmanaged beans, exception handling and more, with concrete code demos and solutions.

Programmer XiaoFu
Programmer XiaoFu
Programmer XiaoFu
Why @Transactional Often Fails: 13 Real‑World Pitfalls from 6 Years of Experience

Unnecessary usage

Applying @Transactional to read‑only methods or to an entire class adds proxy overhead and can unintentionally override a class‑level readOnly=true setting.

@Transactional
public String testQuery() {
    standardBak2Service.getById(1L);
    return "testB";
}

Annotating an abstract base class makes every subclass method transactional, increasing performance cost.

@Transactional
public abstract class BaseService {
}

When a class‑level annotation is present, a method‑level readOnly=true can be overridden, turning a read‑only transaction into a read‑write one.

Too broad scope

Placing @Transactional on a class causes all methods—including non‑transactional ones—to be managed by the proxy.

@Transactional
public class TestMergeService extends BaseService {
    public String testMerge() {
        testAService.testA();
        return "ok";
    }
}

Ineffective usage

Private methods

Spring AOP creates proxies; private methods cannot be intercepted, so @Transactional on a private method never takes effect.

@Transactional
private String testMerge() {
    testAService.testA();
    testBService.testB();
    return "ok";
}

Final / static methods

Methods declared final or static cannot be overridden or proxied; the annotation is ignored.

@Transactional
public static void b() { }

@Transactional
public final void b() { }

Self‑invocation (same‑class calls)

Calling another method of the same class bypasses the proxy, so the called method's transaction is not applied.

@Transactional
public String testMerge() {
    a();
    b(); // b() throws RuntimeException
    return "ok";
}

public void a() {
    standardBakService.save(testAService.buildEntity());
}

public void b() {
    standardBak2Service.save(testBService.buildEntity2());
    throw new RuntimeException("b error");
}

If testMerge() is not transactional, both a() and b() run without a transaction because the call goes through this directly.

Solutions

Extract the called method into a separate @Service bean so that Spring can proxy it.

Inject the proxy itself (e.g., AopContext.currentProxy()) or self‑inject the bean and invoke the method on the proxy.

// Using AopContext
public String testMerge() {
    a();
    ((TestMergeService) AopContext.currentProxy()).b();
    return "ok";
}

Non‑rollback scenarios

Propagation settings

The propagation attribute controls how transactions are joined or created. Mis‑configuration leads to unexpected commit/rollback behavior. The seven propagation types are: REQUIRED: default, joins existing or creates new. MANDATORY: requires an existing transaction, otherwise throws IllegalTransactionStateException. NEVER: must not run within a transaction, throws if one exists. REQUIRES_NEW: always creates a new transaction, suspending the current one. NESTED: creates a nested transaction that rolls back with the outer one. SUPPORTS: runs within a transaction if present, otherwise non‑transactionally. NOT_SUPPORTED: forces non‑transactional execution, suspending any existing transaction.

Exception swallowing

Catching exceptions without re‑throwing prevents Spring from seeing the failure, so the transaction does not roll back.

@Transactional
public String testMerge() {
    try {
        testAService.testA();
        testBService.testB();
    } catch (Exception e) {
        log.error("testMerge error:{}", e);
        // missing re‑throw -> no rollback
    }
    return "ok";
}

Re‑throwing a RuntimeException (or configuring rollbackFor) restores rollback behavior.

Checked exceptions

By default Spring rolls back only RuntimeException and Error. Checked exceptions such as SQLException do not trigger rollback unless specified with rollbackFor.

@Transactional(rollbackFor = Exception.class)
public String testMerge() throws Exception {
    try {
        testAService.testA();
        testBService.testB();
    } catch (Exception e) {
        log.error("testMerge error:{}", e);
        throw new Exception(e); // will roll back because of rollbackFor
    }
    return "ok";
}

Async thread execution

Transactions are bound to the thread via ThreadLocal. Starting a new thread breaks the transaction context, so operations in the new thread cannot roll back the original transaction.

@Transactional
public String testMerge() {
    testAService.testA();
    new Thread(() -> {
        try {
            testBService.testB();
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }).start();
    return "ok";
}

In this case testA() does not roll back, while testB() rolls back within its own thread.

Bean not managed by Spring

Only beans created by Spring ( @Component, @Service, @Controller) can be proxied. Classes without these stereotypes will never have transactional behavior.

// No Spring stereotype -> @Transactional ignored
public class TestBService {
    @Transactional
    public String testB() { /* ... */ }
}

Propagation examples

REQUIRED (default)

If the caller has a transaction, the callee joins it; otherwise a new transaction is created.

@Component
@Service
public class TestMergeService {
    @Transactional
    public String testMerge() {
        testAService.testA();
        testBService.testB();
        return "ok";
    }
}

@Transactional
public String testA() { /* ... */ }

@Transactional
public String testB() {
    // ...
    throw new RuntimeException("testB");
}

MANDATORY

Method must be invoked within an existing transaction; otherwise IllegalTransactionStateException is thrown.

@Transactional(propagation = Propagation.MANDATORY)
public String testB() {
    // ...
    throw new RuntimeException("testB");
}
org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'

NEVER

Method must not run within a transaction; if a transaction exists, an exception is thrown.

@Transactional(propagation = Propagation.NEVER)
public String testB() {
    // ...
    return "ok";
}
org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never'

REQUIRES_NEW

Always creates a new transaction, suspending any existing one. If the new transaction rolls back, the outer transaction remains unaffected.

@Transactional
public String testMerge() {
    testAService.testA();
    testBService.testB(); // runs in a separate transaction
    return "ok";
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public String testB() {
    // ...
    throw new RuntimeException("testB");
}

NESTED

Creates a nested transaction that rolls back together with the outer transaction, but a rollback of the nested transaction does not affect the outer one.

@Transactional
public String testMerge() {
    testAService.testA();
    testBService.testB(); // nested
    throw new RuntimeException("testMerge"); // outer rolls back
}

NOT_SUPPORTED

Method runs non‑transactionally, suspending any existing transaction.

@Transactional
public String testMerge() {
    testAService.testA(); // participates in outer transaction
    testBService.testB(); // runs without transaction
    return "ok";
}

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public String testB() {
    // ...
    throw new RuntimeException("testB");
}

SUPPORTS

Method participates in a transaction only if the caller has one; otherwise it executes non‑transactionally.

@Transactional(propagation = Propagation.SUPPORTS)
public String testA() { /* ... */ }

Additional pitfalls and work‑arounds

Self‑injection and proxy retrieval

Self‑injecting the bean or obtaining the current proxy via AopContext.currentProxy() allows a method to invoke another method on the proxy, ensuring the transaction is applied.

@Service
public class TestMergeService {
    @Autowired
    private TestMergeService self; // self‑injection

    public String testMerge() {
        a();
        self.b(); // goes through proxy
        return "ok";
    }

    @Transactional
    public void b() { /* ... */ }
}

Async execution and ThreadLocal

Because the transaction context is stored in a ThreadLocal, new threads do not inherit the transaction. This explains why testA() does not roll back while testB() does within its own thread.

Exception type and rollbackFor

Only unchecked exceptions trigger rollback by default. To roll back on checked or custom exceptions, specify them in rollbackFor.

@Transactional(rollbackFor = CustomException.class)
public String testMerge() throws CustomException { /* ... */ }
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaAOPException HandlingSpringSpring BootTransactionalPropagation
Programmer XiaoFu
Written by

Programmer XiaoFu

xiaofucode.com – a programmer learning guide driven by the pursuit of profit

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.