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
@Transactionalannotation 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
@Transactionalto methods that only perform queries or HTTP calls adds no value and violates coding standards; it should be removed.
<code>@Transactional
public String testQuery() {
standardBak2Service.getById(1L);
return "testB";
}
</code>2. Transaction scope too large
Annotating a class or abstract class with
@Transactionalmakes every method inherit transaction management, causing unnecessary performance overhead. Use the annotation only on methods that truly need it.
<code>@Transactional
public abstract class BaseService {
}
@Slf4j
@Service
public class TestMergeService extends BaseService {
private final TestAService testAService;
@Transactional
public String testMerge() {
testAService.testA();
return "ok";
}
}
</code>Not Effective
3. Method visibility issues
Private methods cannot be proxied by Spring AOP, so
@Transactionalon a private method never takes effect.
<code>@Transactional
private String testMerge() {
testAService.testA();
testBService.testB();
return "ok";
}
</code>When a public method calls a private method, the transaction still works because the proxy intercepts the public method.
<code>@Transactional
public String testMerge() throws Exception {
ccc();
return "ok";
}
private void ccc() {
testAService.testA();
testBService.testB();
}
</code>4. Final or static methods
Methods declared
finalor
staticcannot be intercepted, so the annotation is ignored.
<code>@Transactional
public static void b() {}
@Transactional
public final void b() {}
</code>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().
<code>// 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";
}
</code>6. Bean not managed by Spring
The transaction mechanism works only for beans instantiated by Spring (e.g., annotated with
@Component,
@Service, or
@Controller).
<code>@Service
public class TestBService {
@Transactional
public String testB() {
// ...
return "testB";
}
}
</code>7. Asynchronous thread calls
Transactions are thread‑local; executing transactional code in a new thread prevents rollback propagation.
<code>@Transactional
public String testMerge() {
testAService.testA();
new Thread(() -> {
try {
testBService.testB();
} catch (Exception e) {
throw new RuntimeException();
}
}).start();
return "ok";
}
</code>Not Rollback
9. Wrong propagation attribute
The
propagationsetting 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
RuntimeExceptionor configure
rollbackForaccordingly.
<code>@Transactional
public String testMerge() {
try {
testAService.testA();
testBService.testB();
} catch (Exception e) {
log.error("testMerge error:{}", e);
throw new RuntimeException(e);
}
return "ok";
}
</code>11. Exceptions not causing rollback
Spring rolls back on unchecked exceptions (
RuntimeExceptionand
Error) by default. Checked exceptions (e.g.,
SQLException) do not trigger rollback unless specified with
rollbackFor.
<code>@Transactional(rollbackFor = Exception.class)
public String testMerge() throws Exception {
// ...
throw new Exception(e);
}
</code>12. Custom exception scope
When using custom exceptions, ensure they extend
RuntimeExceptionor declare them in
rollbackForto guarantee rollback.
<code>@Transactional(rollbackFor = CustomException.class)
public String testMerge() throws Exception {
// ...
throw new CustomException(e);
}
</code>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.
<code>@Transactional
public String testMerge() {
testAService.testA();
try {
testBService.testB();
} catch (Exception e) {
log.error("testMerge error:{}", e);
}
return "ok";
}
</code>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.