Environment
SonarQube Server: 26.4.0.121862
SonarJava plugin: 8.27.0.43088
Java: 21.0.8
Spring Boot: 4.1.0
Spring Framework: 7.0.8
Build: Maven
Rule: java:S2229
Background
S2229 correctly detects that Spring @Transactional annotations are not applied through same-class self-invocation when proxy-based transaction management is used.
However, in the affected code, the transaction is not expected to be created by the callee’s @Transactional annotation. The calls are executed inside an explicit transaction created by TransactionTemplate.
The template is configured with PROPAGATION_REQUIRES_NEW:
batchTransactionTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW
);
The affected methods are then invoked from a callback passed to the template:
batchTransactionTemplate.execute(status -> {
// ...
return operation.apply(resource);
});
The callback eventually invokes retry(...), cancel(...), or deleteResource(...) through same-class invocation.
Although the @Transactional proxy is bypassed for these calls, the callback is already executing inside a REQUIRES_NEW transaction created by TransactionTemplate. The called methods use the default PROPAGATION_REQUIRED behavior, so their transaction requirement is already satisfied by the surrounding programmatic transaction.
Therefore, there is no missing transaction boundary or incompatible transaction propagation requirement in this execution path.
Spring documents that self-invocation bypasses the transactional proxy:
The observed behavior suggests that S2229 recursively analyzes method invocations inside the lambda without modeling the transactional context introduced by TransactionTemplate.execute(...) or executeWithoutResult(...).
Relevant analyzer implementation:
Minimal reproducible example
import java.util.UUID;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
public class S2229TransactionTemplateFalsePositive {
private final TransactionTemplate transactionTemplate;
public S2229TransactionTemplateFalsePositive(
PlatformTransactionManager transactionManager) {
this.transactionTemplate =
new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
/*
* This method is also exposed as a public service operation.
* Its @Transactional annotation is meaningful when the method
* is invoked externally through the Spring proxy.
*/
@Transactional
public void retry(UUID id) {
// Database changes
}
public void retryBatch(UUID id) {
transactionTemplate.executeWithoutResult(status -> {
retry(id); // S2229 is reported here
});
}
}
Actual behavior
S2229 reports the call to retry(id):
"retry's" @Transactional requirement is incompatible with the one for this method.
Expected behavior
No issue should be reported on retry(id).
The call is a same-class invocation, so the transactional interceptor on retry(...) is not applied. However, the method is already executing inside the transaction created by TransactionTemplate.executeWithoutResult(...), which satisfies its default PROPAGATION_REQUIRED requirement.
Equivalent calls made outside any declarative or programmatic transaction boundary should continue to be reported.
If this minimal example does not reproduce the issue in your environment, I am willing to share the relevant project code privately to help reproduce and investigate it.
I may be overlooking an intended limitation of S2229, so I would appreciate confirmation on whether this behavior is expected by design or represents a false positive in the analyzer.
I encountered a few unrelated potential false positives in the same analysis, but I am reporting them separately because they affect different rules and analyzer behaviors.