Why @Transactional Often Fails: 13 Real-World Pitfalls and How to Fix Them
An in‑depth guide reveals thirteen common pitfalls that cause Spring’s @Transactional annotation to malfunction—ranging from unnecessary usage and proxy limitations to propagation misconfigurations and exception handling—plus practical demos and solutions to ensure reliable transaction rollbacks in Java backend development.
When taking on a new project, the misuse of the @Transactional annotation can lead to unexpected failures and non‑rollback situations. This article categorizes the problems into three main groups—unnecessary, ineffective, and non‑rollback—and demonstrates each scenario with concrete code examples.
Unnecessary
1. Business without transaction
Applying @Transactional to methods that only perform queries or HTTP calls adds no value and violates coding standards; it should be removed.
@Transactional
public String testQuery() {
standardBak2Service.getById(1L);
return "testB";
}2. Transaction scope too large
Annotating a class or abstract class with @Transactional makes every method inherit transaction management, causing unnecessary performance overhead. Use the annotation only on methods that truly need it.
@Transactional
public abstract class BaseService {
}
@Slf4j
@Service
public class TestMergeService extends BaseService {
private final TestAService testAService;
@Transactional
public String testMerge() {
testAService.testA();
return "ok";
}
}Not Effective
3. Method visibility issues
Private methods cannot be proxied by Spring AOP, so @Transactional on a private method never takes effect.
@Transactional
private String testMerge() {
testAService.testA();
testBService.testB();
return "ok";
}When a public method calls a private method, the transaction still works because the proxy intercepts the public method.
@Transactional
public String testMerge() throws Exception {
ccc();
return "ok";
}
private void ccc() {
testAService.testA();
testBService.testB();
}4. Final or static methods
Methods declared final or static cannot be intercepted, so the annotation is ignored.
@Transactional
public static void b() {}
@Transactional
public final void b() {}5. Internal method calls
Calling another method of the same class bypasses the proxy, causing the transaction to be ineffective. Solutions include extracting the called method to a separate service, self‑injection, or manually obtaining the proxy via AopContext.currentProxy().
// Separate service
@Service
public class TestBService {
@Transactional
public void b() {
// ...
throw new RuntimeException("b error");
}
}
// Self‑injection
@Service
public class TestMergeService {
@Autowired
private TestMergeService self;
@Transactional
public void b() { /* ... */ }
public String testMerge() {
a();
self.b();
return "ok";
}
}
// Manual proxy
public String testMerge() {
a();
((TestMergeService) AopContext.currentProxy()).b();
return "ok";
}6. Bean not managed by Spring
The transaction mechanism works only for beans instantiated by Spring (e.g., annotated with @Component, @Service, or @Controller).
@Service
public class TestBService {
@Transactional
public String testB() {
// ...
return "testB";
}
}7. Asynchronous thread calls
Transactions are thread‑local; executing transactional code in a new thread prevents rollback propagation.
@Transactional
public String testMerge() {
testAService.testA();
new Thread(() -> {
try {
testBService.testB();
} catch (Exception e) {
throw new RuntimeException();
}
}).start();
return "ok";
}Not Rollback
9. Wrong propagation attribute
The propagation setting controls how transactions are nested. Misusing it can prevent rollbacks. The seven propagation types are: REQUIRED: default, joins existing or creates new. MANDATORY: requires an existing transaction. NEVER: must not run within a transaction. REQUIRES_NEW: always starts a new transaction. NESTED: creates a nested transaction. SUPPORTS: runs transactionally only if a transaction exists. NOT_SUPPORTED: forces non‑transactional execution.
Examples for each propagation type are provided in the original article.
10. Swallowing exceptions
If a method catches an exception without re‑throwing it, Spring cannot trigger a rollback. Re‑throw a RuntimeException or configure rollbackFor accordingly.
@Transactional
public String testMerge() {
try {
testAService.testA();
testBService.testB();
} catch (Exception e) {
log.error("testMerge error:{}", e);
throw new RuntimeException(e);
}
return "ok";
}11. Exceptions not causing rollback
Spring rolls back on unchecked exceptions ( RuntimeException and Error) by default. Checked exceptions (e.g., SQLException) do not trigger rollback unless specified with rollbackFor.
@Transactional(rollbackFor = Exception.class)
public String testMerge() throws Exception {
// ...
throw new Exception(e);
}12. Custom exception scope
When using custom exceptions, ensure they extend RuntimeException or declare them in rollbackFor to guarantee rollback.
@Transactional(rollbackFor = CustomException.class)
public String testMerge() throws Exception {
// ...
throw new CustomException(e);
}13. Nested transaction handling
To prevent a failure in a nested transactional method from rolling back the outer transaction, catch the exception inside the outer method and handle it without re‑throwing.
@Transactional
public String testMerge() {
testAService.testA();
try {
testBService.testB();
} catch (Exception e) {
log.error("testMerge error:{}", e);
}
return "ok";
}Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
macrozheng
Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
