In a large codebase, how would you detect silently-broken self-invoked @Transactional methods, and why does the same trap apply beyond transactions?
answer
- same trap: @Async, @Cacheable, @Retryable, @PreAuthorize, @Validated
- isActualTransactionActive() assertion
- IntelliJ self-invocation inspection / ArchUnit rule
- integration test the REQUIRES_NEW / rollback boundary
- systemic proxy property, not per-annotation
basics
~20 sThe same proxy limitation breaks any proxy-based aspect — @Async, @Cacheable, @Retryable, @PreAuthorize — when self-invoked. Detect it by asserting a transaction is active at runtime, code review/static analysis for internal calls to annotated methods, and integration tests that verify rollback/isolation.
solid answer
~40 sBecause declarative transactions are just one proxy-based aspect, **every** proxy-based feature has the same self-invocation blind spot: `@Async` runs synchronously, `@Cacheable`/`@CacheEvict` skip the cache, `@Retryable` doesn't retry, `@PreAuthorize` isn't checked, `@Validated` isn't applied — all silently — when the annotated method is reached via `this.`. To detect the transactional cases specifically, I'd: (1) add runtime assertions in critical methods using `TransactionSynchronizationManager.isActualTransactionActive()` / `getCurrentTransactionName()`; (2) write integration tests that force a rollback or REQUIRES_NEW boundary and assert the expected isolation; (3) run static analysis / a custom ArchUnit or detekt rule (or an IDE inspection — IntelliJ flags 'self-invocation ... won't apply') that flags internal calls to `@Transactional`/`@Async`/`@Cacheable` methods; (4) turn on `org.springframework.transaction` TRACE logging in tests. Long-term prevention: prefer bean extraction, and consider AspectJ weaving where self-calls are structurally unavoidable.
code
java · 23 lines// Behavioral test: prove REQUIRES_NEW really commits independently.
@SpringBootTest
class AuditIsolationTest {
@Autowired OrderService orderService;
@Autowired AuditRepository auditRepo;
@Test
void auditSurvivesOuterRollback() {
assertThatThrownBy(() -> orderService.placeOrderThenFail())
.isInstanceOf(RuntimeException.class);
// If placeOrder self-invoked audit(), the REQUIRES_NEW was ignored
// and this row rolled back with the outer tx -> test fails, exposing the bug.
assertThat(auditRepo.count()).isEqualTo(1);
}
}
// Runtime probe inside a must-be-transactional method:
void mustBeTransactional() {
org.springframework.util.Assert.state(
org.springframework.transaction.support.TransactionSynchronizationManager
.isActualTransactionActive(),
"No active transaction - likely a self-invocation bypass");
}go deeper
Not expected; may only know it affects @Transactional.
Should recognize @Async/@Cacheable share the trap.
Should propose concrete detection (tests, logging, isActualTransactionActive) and name several affected annotations.
Should frame it as a systemic proxy property, propose CI-enforced static rules + behavioral tests + an extraction convention, and flag the security angle of @PreAuthorize self-calls.
## Why it generalizes: one mechanism, many annotations `@Transactional` is not special — it is implemented as **AOP advice applied by a proxy**. Spring uses the *same* proxy machinery for a whole family of annotations. So the self-invocation bypass is not a transaction bug; it is a **proxy-model property**. Any of these, when the annotated method is invoked via `this.method()`, is silently skipped: | Annotation | What silently fails on self-call | |---|---| | `@Transactional` | no transaction / wrong propagation | | `@Async` | runs synchronously on the caller thread | | `@Cacheable` / `@CachePut` / `@CacheEvict` | cache not consulted/updated | | `@Retryable` (Spring Retry) | no retry on failure | | `@PreAuthorize` / `@PostAuthorize` / `@Secured` | authorization check skipped | | `@Validated` (method validation) | arguments not validated | | custom `@Around` aspects | advice not applied | The `@Async` and `@PreAuthorize` cases are especially dangerous: performance regressions and **security holes** that pass tests written against external entry points. ## Detection strategies **1. Runtime assertions / probes** Inside a method that *must* be transactional: ```java Assert.state(TransactionSynchronizationManager.isActualTransactionActive(), "expected an active transaction"); ``` Or log `TransactionSynchronizationManager.getCurrentTransactionName()`. If it's null/absent, the advice didn't run. **2. Integration tests that exercise the boundary** - For `REQUIRES_NEW`: make the outer transaction fail *after* the inner method, then assert the inner method's data **survived** (independent commit). If self-invocation broke it, the data rolls back with the outer tx and the test fails. - For rollback semantics: throw inside the method and assert nothing persisted. These catch the *behavioral* consequence, which is what actually matters. **3. Static analysis** - **IDE**: IntelliJ IDEA has an inspection that warns when a method with `@Transactional`/`@Async`/`@Cacheable` is called from within the same class ('self-invocation ... will not lead to an actual transaction'). - **Custom rules**: an ArchUnit test, a detekt/Checkstyle rule, or a Spring Bean Post-Processor that scans for intra-class calls to proxy-advised methods and fails the build. **4. Logging** Enable `logging.level.org.springframework.transaction=TRACE` in a test profile; the absence of 'Creating new transaction' / 'Getting transaction' lines around the inner method reveals the bypass. ## Prevention (architectural) - **Default to bean extraction** — keeping cross-cutting-annotated methods on their own beans means calls are always external, so advice always applies. This is the durable fix. - **Self-injection with `@Lazy`** for genuine one-offs. - **AspectJ weaving** where self-calls are structurally unavoidable or you need advice on non-public methods — accepting the global build/agent cost. - **Team guardrails** — code-review checklist item + the static rule above so new occurrences are caught in CI, not production. ## Principal-level framing The key insight to convey: this is a **systemic property of proxy-based AOP**, not a per-annotation quirk. Fixing one `@Transactional` self-call by rote misses the `@Async`/`@PreAuthorize` instances lurking with the same root cause. The right response is a detection net (static rule + behavioral tests) plus a conventions choice (extraction by default), not case-by-case patching.
- Which self-invoked annotation failure is a security risk, and why is it easy to miss in tests?@PreAuthorize/@Secured. Tests typically call the service from outside (through the proxy), so authorization runs and passes; the internal self-call path that skips the check is never exercised, so the hole ships silently.
- How can TransactionSynchronizationManager help distinguish 'no transaction' from 'wrong transaction'?isActualTransactionActive() tells you whether any physical transaction exists; getCurrentTransactionName() and isCurrentTransactionReadOnly() reveal which one and its attributes — so you can detect both a missing tx and a REQUIRES_NEW that silently joined the outer one.
saying these in an interview costs you the question
- Treating self-invocation as unique to @Transactional
- Assuming external-entry-point tests cover the self-call path
- Believing @Async self-calls still run on a separate thread
- Thinking there's no way to detect it short of reading every method