skip to content

In a large codebase, how would you detect silently-broken self-invoked @Transactional methods, and why does the same trap apply beyond transactions?

level: principalimportance: should knowfreq 30%

answer

  1. same trap: @Async, @Cacheable, @Retryable, @PreAuthorize, @Validated
  2. isActualTransactionActive() assertion
  3. IntelliJ self-invocation inspection / ArchUnit rule
  4. integration test the REQUIRES_NEW / rollback boundary
  5. systemic proxy property, not per-annotation

basics

~20 s

The 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 s

Because 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
java
// 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

for a junior

Not expected; may only know it affects @Transactional.

for a middle

Should recognize @Async/@Cacheable share the trap.

for a senior

Should propose concrete detection (tests, logging, isActualTransactionActive) and name several affected annotations.

for a principal

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

context