skip to content

How would you use MANDATORY and NEVER to enforce transactional boundaries in a layered architecture, and what limits this enforcement?

level: principalimportance: nice to knowfreq 22%

answer

  1. MANDATORY on inner ops = must be inside caller's UoW
  2. NEVER on ops that must stay outside a tx
  3. validation-only, fail fast
  4. proxy-only: self-invocation bypasses
  5. thread-bound: @Async loses the binding

basics

~20 s

Mark low-level operations MANDATORY so they must run inside a caller's transaction, and mark operations that must stay outside one NEVER. Both fail fast with IllegalTransactionStateException. The main limit: the checks only fire through the Spring proxy, so self-invocation bypasses them.

solid answer

~50 s

MANDATORY and NEVER are validation-only propagations, so they make excellent fail-fast contract guards in a layered design. Use MANDATORY on inner-layer operations that must always participate in a unit of work opened higher up — ledger appends, transactional-outbox writes, repository mutations that should never auto-commit alone — turning 'someone forgot the transaction' into an immediate IllegalTransactionStateException in tests. Use NEVER on operations that must never run inside a transaction — e.g. calls you don't want holding DB locks/connections — so an accidental transactional caller fails loudly instead of silently degrading. The enforcement is done by the AOP TransactionInterceptor against thread-bound state, which imposes real limits: self-invocation within a bean bypasses the proxy (the annotation is ignored); the guard only covers Spring-managed transactions on that thread, not manual JDBC or a different resource; and async/new-thread boundaries lose the binding. So treat them as guardrails that catch mistakes early, not as guarantees enforced everywhere.

code

java · 24 lines
java
@Service
public class OrderFacade {
    private final InventoryOps inventory;
    private final OutboxOps outbox;
    OrderFacade(InventoryOps i, OutboxOps o) { this.inventory = i; this.outbox = o; }

    @Transactional // REQUIRED: owns the unit-of-work boundary
    public void placeOrder(Order order) {
        inventory.reserve(order);   // MANDATORY: must join THIS tx
        outbox.record(order);       // MANDATORY: outbox commits with the order
    }
}

@Service
class InventoryOps {
    @Transactional(propagation = Propagation.MANDATORY)
    void reserve(Order order) { /* fails fast if called outside a tx */ }
}

@Service
class OutboxOps {
    @Transactional(propagation = Propagation.MANDATORY)
    void record(Order order) { /* guarantees atomic outbox write */ }
}

go deeper

for a junior

Know MANDATORY forces an existing tx and NEVER forbids one.

for a middle

Give a use case for each and name the exception they throw.

for a senior

Explain fail-fast contract enforcement and the proxy/self-invocation limitation.

for a principal

Design layered boundaries (REQUIRED facade + MANDATORY inner ops), articulate the thread-bound/proxy/Spring-only limits, and back guards with tests.

**Intent:** MANDATORY and NEVER never create, suspend, or resume anything — they only **validate** the current thread's transaction state and then let the method join (MANDATORY) or run outside (NEVER). That makes them cheap, declarative **guardrails** for expressing architectural rules about where transactional boundaries must and must not be. **Using MANDATORY (must be inside a tx):** - Put it on inner/domain operations whose atomicity is only meaningful as part of a larger unit of work: appending to an audit/ledger table, writing a transactional-outbox row that must commit with the business change, repository mutations that should never auto-commit standalone. - Effect: if a caller forgot to open a transaction (or called the operation from the wrong layer), the `TransactionInterceptor` throws `IllegalTransactionStateException` before the body runs. In tests and development this converts a subtle data-integrity bug (partial writes, orphaned rows) into an immediate, obvious failure. It is strictly stronger discipline than REQUIRED, which would silently paper over the missing boundary by creating its own transaction. **Using NEVER (must be outside a tx):** - Put it on operations that must not be captured by an ambient transaction: long-running or external calls you never want prolonging a DB transaction or holding locks/connections, or work you deliberately want auto-committed independently. - Effect: a transactional caller triggers `IllegalTransactionStateException`, surfacing the design violation rather than letting the system quietly hold a transaction open across a slow call. (If instead you want to *tolerate and step around* an ambient transaction, use NOT_SUPPORTED, which suspends/resumes.) **What limits the enforcement (the principal-level nuance):** 1. **Proxy-only / self-invocation.** Spring applies propagation via an AOP proxy. A call from one method of a bean to another (`this.method()`) does not pass through the proxy, so the MANDATORY/NEVER check never runs — the annotation is silently ignored. Enforcement only holds across bean-to-bean calls (or when using `AopContext.currentProxy()` / AspectJ weaving). 2. **Thread-bound scope.** The check reads `TransactionSynchronizationManager`'s thread-local state. Work dispatched to another thread (`@Async`, executors, reactive schedulers) loses that binding, so a MANDATORY method on the new thread will not see the original transaction. 3. **Only Spring-managed transactions count.** Manual JDBC transactions, a second `PlatformTransactionManager` for a different DataSource, or resources not synchronized with Spring won't register as 'active' for these checks. 4. **Runtime, not compile-time.** These are runtime assertions; they catch mistakes only on code paths that execute (hence pair them with tests that exercise the standalone/ambient cases). **Design guidance:** Reserve MANDATORY/NEVER for a small set of load-bearing invariants where a wrong transactional context is a genuine defect; over-using them makes wiring brittle. Combine MANDATORY on inner operations with REQUIRED at the service-facade boundary (the facade owns the transaction; inner ops assert they're inside it). Document the intent, and back the guardrail with an integration test that calls the operation both correctly and incorrectly to prove the exception fires.

  • You annotated an inner method MANDATORY but the check never fires — why?
    Most likely self-invocation: the outer method calls the inner one via 'this', bypassing the Spring proxy so no propagation logic runs. Route the call through an injected bean or AopContext.currentProxy().
  • Why not just use REQUIRED everywhere instead of MANDATORY on inner operations?
    REQUIRED silently opens a transaction if none exists, masking a missing boundary and risking unintended standalone commits; MANDATORY fails fast so the design violation is caught in tests.

saying these in an interview costs you the question

  • Believing MANDATORY/NEVER are enforced even on self-invocations
  • Assuming the thread-bound check survives @Async / new threads
  • Treating them as ways to create/suspend transactions rather than validate

context