Spring Transactions

Spring Transactions

Transaction Propagation

Propagation determines how a @Transactional method behaves when it’s called from within an already-active transaction — i.e., does it join the caller’s transaction, or start something independent?

Propagation Behavior Typical use case
REQUIRED (default) Join the existing transaction if one exists; otherwise start a new one Standard service-layer method — the overwhelming default choice
REQUIRES_NEW Always suspend the current transaction and start a brand-new, fully independent one Audit/event logging that must commit even if the calling transaction later rolls back
NESTED Run within a savepoint inside the existing transaction — a rollback here unwinds only to the savepoint, not the whole outer transaction Partial rollback within a larger unit of work (requires JDBC savepoint support; not supported by all drivers/dialects)
SUPPORTS Join if a transaction exists; run non-transactionally if not Read methods callable both inside and outside a transactional context
NOT_SUPPORTED Suspend any existing transaction, run without one Calling a legacy/non-transactional resource that must not be wrapped
NEVER Throws if called within an existing transaction Enforcing “this must never run as part of a larger transaction”
MANDATORY Throws if called without an existing transaction Enforcing “this must only be called from within a transaction”

The REQUIRES_NEW example that comes up constantly in interviews:

@Service
public class OrderService {

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(buildOrder(request));
        auditService.logAttempt(order.getId());   // see below — runs in its OWN transaction
        inventoryService.reserve(order);           // if this throws, order save rolls back...
    }                                               // ...but the audit log entry already committed independently
}

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAttempt(Long orderId) {
        auditRepository.save(new AuditEntry(orderId, Instant.now()));
    }
}

Architect-level point: REQUIRES_NEW physically suspends the outer transaction’s connection/resources for the duration of the inner one — this means two separate DB connections are held briefly, with connection-pool sizing implications under high concurrency. It’s the right tool specifically because audit/compliance logs need to survive a business rollback, but it’s overused when teams reach for it without understanding the resource cost — a fair amount of “why did connection pool exhaustion happen under load” incidents trace back to liberal REQUIRES_NEW usage in hot paths.


Isolation Levels

Isolation governs how concurrent transactions see each other’s uncommitted/in-flight changes — the classic trade-off between consistency and concurrency throughput.

Isolation Level Dirty Read Non-Repeatable Read Phantom Read Notes
READ_UNCOMMITTED Possible Possible Possible Rarely used in practice; almost no RDBMS defaults here
READ_COMMITTED Prevented Possible Possible Default for PostgreSQL, SQL Server, Oracle
REPEATABLE_READ Prevented Prevented Possible (MySQL InnoDB actually prevents phantoms too, via gap locks) Default for MySQL/InnoDB
SERIALIZABLE Prevented Prevented Prevented Strongest guarantee, lowest concurrency — transactions effectively run as if sequential

Anomaly definitions worth having crisp for an interview:

  • Dirty read — reading another transaction’s uncommitted change (which might later roll back).
  • Non-repeatable read — re-reading the same row within a transaction returns different values because another transaction committed a change in between.
  • Phantom read — re-running the same range query within a transaction returns a different set of rows because another transaction inserted/deleted matching rows in between.
@Transactional(isolation = Isolation.READ_COMMITTED)
public BigDecimal getAccountBalance(Long accountId) { ... }

@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
    // strongest guarantee — appropriate where correctness fully dominates throughput concerns
}

Architect-level nuance: isolation level is ultimately a database-engine setting that Spring/JPA only requests — most teams should default to the DB’s native default (READ_COMMITTED for Postgres) and reach for stricter levels surgically, only on the specific operations where a race condition has a real correctness cost (financial transfers, inventory decrement under contention), rather than blanket-applying SERIALIZABLE and absorbing a throughput hit everywhere. In practice, optimistic locking (@Version) is frequently the better tool than escalating isolation level for the common “two users editing the same record” scenario — it avoids holding DB locks at all and instead detects the conflict at commit time.


Rollback Rules

Default behavior — the single most misunderstood detail of @Transactional:

  • Unchecked exceptions (RuntimeException and its subclasses, including Error) → trigger automatic rollback.
  • Checked exceptions (anything extending Exception but not RuntimeException) → do NOT trigger rollback by default; the transaction commits even though an exception propagated out.
@Transactional
public void process(Order order) throws OrderValidationException { // checked exception
    orderRepository.save(order);
    if (!isValid(order)) {
        throw new OrderValidationException("Invalid order"); // transaction STILL COMMITS by default!
    }
}

Overriding the default explicitly — required whenever checked exceptions carry business-failure semantics:

@Transactional(rollbackFor = OrderValidationException.class)
public void process(Order order) throws OrderValidationException {
    orderRepository.save(order);
    if (!isValid(order)) {
        throw new OrderValidationException("Invalid order"); // now correctly rolls back
    }
}

The inverse, noRollbackFor, lets you commit despite a specific unchecked exception type — rarer, but valid for cases like a non-critical notification failure that shouldn’t undo the actual business operation it’s reporting on.

Why Spring chose this default: the convention mirrors the original EJB CMT (Container-Managed Transactions) behavior — checked exceptions were historically treated as recoverable/expected business outcomes, unchecked exceptions as unexpected failures warranting rollback. It’s a 20+ year old design decision that still trips up developers because intuition says “any exception thrown should obviously roll back the transaction” — it doesn’t, unless you say so.

Architect-level guidance for code reviews: if a service method’s checked exceptions represent genuine failure states (not just an alternate-but-valid outcome), rollbackFor should be specified explicitly rather than relying on default behavior — leaving it implicit is a latent data-integrity bug waiting for the first checked-exception path to actually be hit in production.


Common Pitfalls

1. Self-invocation (the recurring AOP-proxy issue across @Transactional, @PreAuthorize, custom aspects):

@Service
public class OrderService {
    public void process(Order order) {
        this.save(order); // bypasses the proxy — NO transaction starts
    }
    @Transactional
    public void save(Order order) { ... }
}

Covered in depth earlier — worth restating here because it’s specifically a transaction pitfall as often as it’s an AOP one, and it’s the #1 “why isn’t my rollback working” root cause reported in practice.

2. Swallowing exceptions inside the transactional method:

@Transactional
public void process(Order order) {
    try {
        riskyOperation();
    } catch (Exception e) {
        log.error("failed", e); // exception caught and logged, never rethrown
    }
    // method returns NORMALLY — Spring sees a clean return, COMMITS despite the failure
}

If a failure must cause rollback, it must propagate out of the method (or you must call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() explicitly from within the catch block) — catching and silently logging is a guaranteed silent-data-corruption pattern.

3. readOnly = true used incorrectly, or its benefit misunderstood:

@Transactional(readOnly = true)
public void updateStatus(Long id) {
    orderRepository.updateStatus(id, OrderStatus.SHIPPED); // a WRITE inside a readOnly transaction
}

readOnly is a hint, not an enforced constraint at the Spring level (though some drivers/dialects will actually reject writes) — it tells Hibernate to skip dirty-checking and set flush mode appropriately for a perf gain, but using it on a method that actually writes is, at best, relying on undefined/driver-specific behavior, and at worst silently skips expected flush behavior.

4. Long-running transactions holding DB connections under load: A @Transactional method that makes an external HTTP call, sends a message to a queue, or does any slow I/O inside the transaction boundary holds a DB connection (and potentially row locks) for the entire duration — under load this exhausts the connection pool fast. The fix is almost always to narrow the transactional boundary to just the DB operations and move slow I/O outside it (e.g., publish the order, then call the external service afterward, or use the outbox pattern for guaranteed eventual delivery instead of doing it inline).

5. Transaction boundary at the wrong layer: Putting @Transactional on a @RestController method (rather than the @Service layer) couples the HTTP request lifecycle to the transaction lifecycle and is a common architecture-review finding — the transaction boundary should align with a business use case (service layer), not with the web framework’s request/response cycle, which may include serialization, validation, and other work that has no business being inside a DB transaction.

6. Forgetting that @Transactional on a private method does nothing: Because the AOP proxy can only intercept calls that arrive through the proxy’s exposed interface/overridable methods — private methods are invisible to the proxy entirely (can’t be overridden by CGLIB, can’t be part of an interface for JDK proxies). @Transactional (and @Async, @Cacheable, @PreAuthorize) silently do nothing on private methods, with no compile-time or runtime warning — a quiet, easy-to-miss bug.