Hire Me
← All Writing Spring Boot

Transaction Management Deep Dive

A thorough guide to Spring transaction management — how @Transactional actually works, propagation behaviours, common failure modes, transaction boundaries with MongoDB and JPA, and the patterns that keep complex services correct under failure.

@Transactional is one of the most commonly used annotations in Spring Boot and one of the least understood. I’ve reviewed codebases where it was sprinkled on every service method as a vague safety net, and others where it was missing entirely from methods that desperately needed it. Both are wrong. Getting transaction management right — understanding what the annotation actually does, where it fails silently, and how to design boundaries correctly — is foundational to building systems that behave correctly under failure.

What @Transactional Actually Does

@Transactional is implemented via Spring AOP. When you annotate a method, Spring wraps the bean in a proxy. The proxy opens a transaction before the method executes and commits or rolls back when it returns. The annotated class itself knows nothing about transactions — it’s the proxy that handles everything.

This has an immediate implication: calling a @Transactional method from within the same class bypasses the proxy entirely. The transaction is never opened.

@Service
public class ClaimService {

    // This @Transactional is ignored when called from processAndNotify below
    @Transactional
    public void updateStatus(String claimId, ClaimStatus status) {
        claimRepository.updateStatus(claimId, status);
    }

    public void processAndNotify(String claimId) {
        updateStatus(claimId, ClaimStatus.APPROVED);  // NO transaction — self-invocation
        notificationService.send(claimId);
    }
}

The fix is to inject the service into itself (via @Autowired or @Lazy), or — better — restructure so transactional logic doesn’t call itself. The self-injection approach works but is a code smell; restructuring is cleaner.

Propagation Behaviours

The propagation attribute controls what happens when a transactional method is called from within an existing transaction. The defaults are almost always wrong for at least one scenario in a complex service.

REQUIRED (default) — joins an existing transaction if one exists; opens a new one if not. Most methods want this.

REQUIRES_NEW — always opens a new, independent transaction, suspending the outer one. Use this when you need a side effect to commit regardless of whether the outer transaction rolls back — for example, writing an audit log entry that must survive even if the main operation fails:

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logDecision(String claimId, String decision) {
        auditRepository.save(new AuditEntry(claimId, decision, Instant.now()));
    }
}

If AuditService.logDecision is called from within a REQUIRED transaction that subsequently rolls back, the audit entry is still committed because it ran in its own independent transaction.

NESTED — creates a savepoint within the existing transaction. If the nested operation fails, you roll back to the savepoint without rolling back the whole outer transaction. Only supported with JDBC — not available with MongoDB.

NOT_SUPPORTED — suspends any existing transaction and runs without one. Useful for read-heavy operations where you deliberately want to avoid transaction overhead and are happy with dirty reads.

NEVER — throws an exception if called within an existing transaction. Use this to enforce that certain operations must never run inside a transaction — useful as a guardrail during development.

Rollback Rules

By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) but not on checked exceptions. This is a historical accident inherited from EJB and trips up nearly everyone at least once:

@Transactional
public void processPayment(String claimId) throws PaymentException {
    claimRepository.updateStatus(claimId, ClaimStatus.PAYMENT_PENDING);
    paymentGateway.submit(claimId); // throws checked PaymentException on failure
    // If PaymentException is thrown, the repository write is COMMITTED, not rolled back
}

The fix:

@Transactional(rollbackFor = PaymentException.class)
public void processPayment(String claimId) throws PaymentException {
    claimRepository.updateStatus(claimId, ClaimStatus.PAYMENT_PENDING);
    paymentGateway.submit(claimId);
}

Or convert the checked exception to a RuntimeException at the boundary — which is usually the right architectural call anyway. Checked exceptions in service layers often indicate a domain concept that should be modelled explicitly rather than left to the exception hierarchy.

Transactions with MongoDB

MongoDB supports multi-document transactions from version 4.0+, but only on replica sets. Spring Data MongoDB honours @Transactional when a MongoTransactionManager is configured:

@Configuration
public class MongoTransactionConfig {

    @Bean
    public MongoTransactionManager transactionManager(MongoDatabaseFactory factory) {
        return new MongoTransactionManager(factory);
    }
}

With this in place, @Transactional behaves as expected across multiple repository operations:

@Transactional
public void transferClaimOwnership(String claimId, String newOwnerId) {
    BenefitClaim claim = claimRepository.findById(claimId)
        .orElseThrow(() -> new ClaimNotFoundException(claimId));
    claim.reassignTo(newOwnerId);
    claimRepository.save(claim);

    ownershipLogRepository.save(new OwnershipTransfer(claimId, newOwnerId, Instant.now()));
    // Both writes commit atomically or neither does
}

There are two things to watch for with MongoDB transactions. First, they add latency — a transactional write requires a round-trip to the primary to begin the transaction and another to commit it. For high-throughput paths, consider whether the atomicity is genuinely required or whether idempotent single-document writes can achieve the same safety. Second, MongoDB transactions lock documents — concurrent updates to the same document within a transaction will contend. Design your document model to keep transaction scope narrow.

Read-Only Transactions

Marking a transaction readOnly = true is not just documentation — it’s a hint the framework and database driver can act on:

@Transactional(readOnly = true)
public List<BenefitClaim> findPendingClaims() {
    return claimRepository.findByStatus(ClaimStatus.PENDING);
}

With JPA/Hibernate, readOnly = true disables dirty checking (Hibernate won’t snapshot entities to detect changes), which reduces memory allocation and CPU overhead significantly on large result sets. With MongoDB it’s primarily a documentation and routing hint — some drivers can route read-only transactions to secondaries for load distribution.

Don’t skip readOnly = true on query methods. It’s low cost to add and can meaningfully reduce overhead in high-read services.

Transaction Boundaries and Service Design

The most common transaction design mistake I see is transactions that span too much:

@Transactional
public void processClaimBatch(List<String> claimIds) {
    for (String claimId : claimIds) {
        processOneClaim(claimId);           // DB writes
        externalEligibilityService.check(); // external HTTP call
        notificationService.send(claimId); // more DB writes
    }
}

This is wrong for several reasons. External HTTP calls inside a transaction hold the transaction open for the duration of the network round-trip — potentially seconds. If the external call is slow, you’re holding database resources (connections, locks) for that entire time. If the external call fails partway through the batch, you roll back all preceding writes, potentially undoing significant work.

The correct pattern is to keep transactions short and push I/O outside them:

public void processClaimBatch(List<String> claimIds) {
    for (String claimId : claimIds) {
        // 1. Fetch data (outside or short read transaction)
        BenefitClaim claim = claimService.findById(claimId);

        // 2. Call external service (outside any transaction)
        EligibilityResult result = externalEligibilityService.check(claim);

        // 3. Atomic write (short transaction)
        claimService.applyEligibilityResult(claimId, result);

        // 4. Notification (outside transaction — fire and forget or async)
        notificationService.send(claimId);
    }
}

Each transaction is narrow: one read, one atomic write. The external call happens between them with no transaction held open. Partial failures affect only the current item, not the whole batch.

The Transaction-per-Request Anti-Pattern

Opening a transaction at the controller or request-handler level and holding it open for the entire request lifecycle is a pattern I discourage strongly. It couples your transaction scope to HTTP latency, makes it impossible to reason about what’s held open at any given point, and causes contention under load.

Define transaction boundaries at the service method level, not the request level. Use @Transactional on the specific operations that need atomicity, not as a blanket on every public method.

Testing Transactional Behaviour

Spring’s @Transactional on test methods rolls back the database after each test — a useful feature for keeping test state clean. But it can hide bugs: if your production code relies on a committed state being visible across method calls, a test that runs in one wrapping transaction won’t reproduce that behaviour faithfully.

For integration tests that need to verify committed state, use @Commit to override the default rollback, or use Testcontainers with a real database and manage cleanup manually:

@SpringBootTest
@Testcontainers
class ClaimServiceIntegrationTest {

    @Container
    static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");

    @Test
    void transferOwnership_commitsBothWrites() {
        claimService.transferClaimOwnership("claim-1", "owner-2");

        // Verify both documents were committed
        assertThat(claimRepository.findById("claim-1").get().getOwnerId()).isEqualTo("owner-2");
        assertThat(ownershipLogRepository.findByClaimId("claim-1")).hasSize(1);
    }
}

Transaction correctness is not something you can verify without a real database and committed state. Test it properly.

If you’re building Spring Boot services where correctness under failure is non-negotiable, get in touch.