Spring Boot DAO Pattern and @Transactional: Propagation, Isolation, Read-Only

Master Spring Boot's @Transactional annotation. Learn propagation levels, isolation modes, read-only transactions, failure scenarios, and best practices.

published: reading time: 31 min read author: GeekWorkBench
Quick Summary

Master Spring Boot's @Transactional annotation. Learn propagation levels, isolation modes, read-only transactions, failure scenarios, and best practices. The guide uses practical examples to explain dao pattern in spring boot, how @transactional works and shows how to apply the ideas in a Spring Boot project. It closes with common pitfalls and production checks so you can apply the pattern with fewer surprises.

Spring Boot DAO Pattern and @Transactional: Propagation, Isolation, Read-Only

Spring’s transaction management looks deceptively simple. You annotate a method with @Transactional, and Spring makes it atomic. But propagation behavior trips up most developers at some point. Isolation levels chosen wrong cause dirty reads. Read-only transactions silently skip their promised optimizations. And the self-invocation trap catches nearly everyone the first time they use transactions across layered architecture.

This guide covers the DAO pattern in Spring Boot, how @Transactional actually works under the hood, the seven propagation levels, the five isolation modes, read-only transaction gotchas, failure scenarios you will hit in production, and a checklist you can use before shipping.

DAO Pattern in Spring Boot

Introduction

The DAO pattern separates persistence operations from business logic, while transactions define the boundary within which related database changes succeed or fail together. This guide shows how to structure DAOs in Spring Boot, apply transaction demarcation, and avoid common propagation, rollback, and layering mistakes.

How @Transactional Works

Spring implements @Transactional through AOP proxies. When you annotate a method (or a class), Spring creates a proxy around the bean. The proxy wraps the actual method call with transaction logic: begin before the method runs, commit or rollback after it finishes. This is the same AOP mechanism covered in Spring Framework Core.

There are two things that trips people up immediately. First, the proxy intercepts calls that come through the Spring bean proxy. If you call a @Transactional method from within the same class, the proxy is bypassed entirely. Second, the default behavior depends on the annotation placement. Applied at the class level, every public method in that class inherits the transaction settings.

@Service
public class UserService {

    @Autowired
    private UserDao userDao;

    @Transactional
    public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
        // This runs inside a transaction
        User from = userDao.findById(fromId);
        User to = userDao.findById(toId);

        from.withdraw(amount);
        to.deposit(amount);

        userDao.update(from);
        userDao.update(to);
        // Transaction commits on normal exit, rolls back on unchecked exception
    }

    public void doTransferWrong() {
        // Calling the transactional method from within the same class
        // Bypasses the proxy - NO transaction!
        transferFunds(1L, 2L, new BigDecimal("100"));
    }
}

This is the self-invocation problem. The doTransferWrong method calls transferFunds on this, which never touches the proxy. The fix is to self-inject the bean or restructure so external callers go through the proxy.

@Service
public class UserService {

    @Autowired
    private UserDao userDao;

    @Transactional
    public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
        User from = userDao.findById(fromId);
        User to = userDao.findById(toId);
        from.withdraw(amount);
        to.deposit(amount);
        userDao.update(from);
        userDao.update(to);
    }
}

@Service
public class AccountService {

    @Autowired
    private UserService userService;
    @Autowired
    private NotificationService notificationService;

    public void orchestrateTransfer(Long fromId, Long toId, BigDecimal amount) {
        userService.transferFunds(fromId, toId, amount);
        notificationService.sendTransferNotice(fromId, toId);
        // Both calls go through proxies
    }
}

External callers (through another Spring bean) use the proxy, so transactions work as expected.

Transaction Propagation Levels

Propagation controls what happens when a transactional method is called while already running inside a transaction. The called method can join the existing transaction, start a new one, run without any transaction, or require a transaction that must already exist.

Here is the full flow in a Mermaid diagram.

graph TD
    Caller["Caller Method<br/>(has transaction?)"]
    Caller --"Yes"--> CheckProp["Check propagation level"]
    Caller --"No"--> REQ["REQUIRED behavior"]

    CheckProp -->|REQUIRED| JT["Join existing transaction"]
    CheckProp -->|REQUIRES_NEW| NT["Suspend existing<br/>Start new transaction"]
    CheckProp -->|MANDATORY| Error1["Throw exception<br/>NoTransactionActiveException"]
    CheckProp -->|NESTED| SN["Create savepoint<br/>Nested transaction"]
    CheckProp -->|SUPPORTS| JT2["Join if exists<br/>Otherwise: no tx"]
    CheckProp -->|NOT_SUPPORTED| NT2["Suspend existing<br/>No transaction"]
    CheckProp -->|NEVER| Error2["Throw exception<br/>Existing tx found"]

    JT --> Execute["Execute method"]
    NT --> Execute
    SN --> Execute
    JT2 --> Execute

    Execute --> Commit["Commit or Rollback"]
    NT --> Commit
    SN --> Commit
    NT2 --> Commit

    style JT stroke:#00fff9,color:#fff
    style NT stroke:#00fff9,color:#fff
    style SN stroke:#00fff9,color:#fff
    style Error1 stroke:#ff0000,color:#fff
    style Error2 stroke:#ff0000,color:#fff

The seven propagation levels in practice:

Propagation When called with existing tx When called without tx Common use case
REQUIRED (default) Joins existing transaction Creates new transaction Most common; use for typical CRUD
REQUIRES_NEW Suspends existing, starts new Creates new transaction Independent logging, audit trails
MANDATORY Joins existing transaction Throws exception Enforce caller provides transaction
NESTED Creates savepoint, nested tx Creates new transaction Partial rollback within larger tx
SUPPORTS Joins if exists Runs without transaction Read-only queries that can work with or without tx
NOT_SUPPORTED Suspends existing Runs without transaction Disabled transaction for test isolation
NEVER Throws exception Runs without transaction Ensure method never runs in transaction

REQUIRED is the default and what you reach for 90% of the time. REQUIRES_NEW is the one that causes confusion in practice. When you call a REQUIRES_NEW method from within a transaction, Spring suspends the current transaction, starts a completely new one, runs your method, commits it, then resumes the suspended transaction. If the outer transaction later rolls back, the inner REQUIRES_NEW commit stays. This is usually what you want for things like audit logs, but it can cause data inconsistency if you assume everything rolls back together.

NESTED uses database savepoints. If you roll back the nested transaction, only that savepoint rolls back and the outer transaction continues. This only works with databases that support savepoints (most JDBC drivers and JPA providers do). It is lighter weight than REQUIRES_NEW when you need partial rollback capability.

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private PaymentService paymentService;

    @Transactional
    public void placeOrder(Long customerId, List<OrderItem> items, PaymentInfo payment) {
        Order order = new Order(customerId, items);
        orderRepository.save(order);

        // Audit should commit independently, not roll back with the order
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        void saveAuditLog(Long orderId, String action) {
            auditLogRepository.save(new AuditLog(orderId, action));
        }

        try {
            paymentService.chargePayment(payment);
        } catch (PaymentFailedException e) {
            // NESTED savepoint allows us to roll back just the payment failure
            // while keeping the order saved
            throw e;
        }
    }
}

Isolation Levels

Isolation levels control how transactions see each other’s changes. The standard four levels, plus Spring’s DEFAULT, and what each guarantees:

Isolation Level Dirty Reads Non-Repeatable Reads Phantom Reads Description
DEFAULT Depends on DB Depends on DB Depends on DB Uses database default
READ_UNCOMMITTED Possible Possible Possible Lowest isolation, fastest
READ_COMMITTED Prevented Possible Possible Oracle default, solid choice
REPEATABLE_READ Prevented Prevented Possible MySQL default
SERIALIZABLE Prevented Prevented Prevented Highest isolation, slowest

Dirty reads are when you see uncommitted changes from another transaction. Non-repeatable reads are when the same row returns different values within a single transaction because another transaction modified and committed it. Phantom reads are when a range query returns different rows because another transaction inserted or deleted rows in that range.

@Transactional(isolation = Isolation.READ_COMMITTED)
public Order findOrderWithItems(Long orderId) {
    Order order = orderRepository.findById(orderId);
    List<OrderItem> items = orderItemRepository.findByOrderId(orderId);
    order.setItems(items);
    return order;
}

Choosing an isolation level is a performance vs correctness tradeoff. READ_COMMITTED prevents dirty reads and is usually sufficient. SERIALIZABLE prevents all concurrency anomalies but can cause significant lock contention and deadlocks under load. READ_UNCOMMITTED is almost never the right answer because it allows reading uncommitted data.

Read-Only Transactions

The readOnly = true flag on @Transactional sounds like a performance hint, and Spring documentation sometimes implies it is one. But its effect varies by database and JPA provider.

@Transactional(readOnly = true)
public List<User> findAllUsers() {
    return userRepository.findAll();
}

With Hibernate and most JDBC drivers, readOnly = true tells the session to skip dirty checking and skip flush before the transaction commits. This saves CPU and memory. Some databases also route read-only connections to read replicas.

But here is the catch: if you mark a transaction readOnly = true and then perform a write operation inside it, the write usually silently succeeds in Hibernate. The database receives it, the operation executes, and no exception is thrown. This is because Hibernate’s Session does not validate that only reads occur in a read-only transaction. Some JPA implementations may throw an exception, but Hibernate stays quiet.

@Transactional(readOnly = true)
public void updateUsername(Long userId, String newName) {
    // This will succeed silently in most Hibernate configurations
    // The database will process the UPDATE
    User user = userRepository.findById(userId);
    user.setUsername(newName);
    userRepository.save(user);
    // No exception thrown, but readOnly flag was ignored
}

Always use readOnly = true only on methods that genuinely only read data, and verify with integration tests that writes are not happening in read-only transactions.

Some databases route read-only transactions to read replicas in a replicated setup. This is not automatic in Spring itself, but requires a ReadOnlyDataSource routing configuration. If you are running a primary-replica MySQL or PostgreSQL setup, you need to configure AbstractRoutingDataSource to route readOnly = true transactions to replicas.

When to Use @Transactional

Use @Transactional on any business operation that reads and writes multiple pieces of data that must be consistent together. The classic case is a multi-step operation where partial completion would leave your system in an invalid state.

Typical cases where you want @Transactional:

  • Creating a user account that requires inserting into multiple tables (user profile, preferences, authentication)
  • Processing an order that reserves inventory, charges payment, and creates order records
  • Transferring funds between accounts where both must update atomically
  • Any operation that reads data and then writes derived or calculated values based on that read
@Transactional
public void createUserWithProfile(String username, String email, UserProfile profile) {
    User user = new User(username, email);
    userRepository.save(user);
    profile.setUserId(user.getId());
    userProfileRepository.save(profile);
    permissionRepository.grantDefaultPermissions(user.getId());
}

When Not to Use @Transactional

Do not annotate every DAO method with @Transactional. Spring Data JPA repositories already handle transactions internally for single-operation methods. Adding @Transactional on single-read or single-write operations is redundant and adds proxy overhead.

Avoid @Transactional on:

  • Simple single-operation repository methods (Spring Data already wraps them)
  • Administrative batch jobs where you want to process records individually and handle failures per-record
  • Methods that call external services where you want the external call outside the transaction boundary
  • Test methods using @Transactional test slices that need to verify actual database state
// NOT NEEDED - Spring Data already handles this
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // Spring Data wraps save() in a transaction automatically
}

// NEEDED - multi-table consistency required
@Transactional
public void closeAccount(Long userId) {
    userRepository.deleteById(userId);
    auditRepository.save(new AuditEntry("ACCOUNT_CLOSED", userId));
    notificationRepository.save(new Notification(userId, "Account closed"));
}

Failure Scenarios

Deadlocks

When two transactions each hold locks the other needs, neither can proceed. With REQUIRED propagation, this is most common when two concurrent transactions update tables in opposite order.

@Transactional
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
    // Transaction A: locks user 1, then user 2
    // Transaction B: locks user 2, then user 1
    // If both run simultaneously, deadlock occurs
    User from = userRepository.findById(fromId);
    User to = userRepository.findById(toId);
    from.withdraw(amount);
    to.deposit(amount);
}

public void batchTransfer(List<Transfer> transfers) {
    // WRONG ORDER: Transaction A -> transfer 1 (user1 -> user2)
    //               Transaction B -> transfer 2 (user2 -> user1)
    // Deadlock guaranteed under concurrent execution
    for (Transfer t : transfers) {
        transferFunds(t.fromId, t.toId, t.amount);
    }
}

The fix is consistent lock ordering. Always acquire locks in the same order across all transactions that access the same tables. Sort entities by ID before updating.

@Transactional
public void transferFundsSorted(Long fromId, Long toId, BigDecimal amount) {
    // Always lock in ID order to prevent deadlock
    Long firstId = fromId.compareTo(toId) < 0 ? fromId : toId;
    Long secondId = fromId.compareTo(toId) < 0 ? toId : fromId;

    User first = userRepository.findById(firstId);
    User second = userRepository.findById(secondId);

    if (firstId.equals(fromId)) {
        first.withdraw(amount);
        second.deposit(amount);
    } else {
        second.withdraw(amount);
        first.deposit(amount);
    }
}

Rollback Issues with Checked Exceptions

By default, @Transactional only rolls back on unchecked exceptions (runtime exceptions and errors). Checked exceptions do not trigger rollback by default.

@Transactional
public void processPayment(Order order, PaymentInfo payment) throws PaymentException {
    try {
        paymentGateway.charge(payment);
        order.markPaid();
        orderRepository.save(order);
    } catch (PaymentException e) {
        // Checked exception - by default this does NOT roll back
        throw e;
    }
}

Use rollbackFor to explicitly specify which exceptions should trigger rollback.

@Transactional(rollbackFor = PaymentException.class)
public void processPayment(Order order, PaymentInfo payment) throws PaymentException {
    paymentGateway.charge(payment);
    order.markPaid();
    orderRepository.save(order);
}

Similarly, noRollbackFor lets you specify exceptions that should not cause rollback even though they are unchecked.

@Transactional(noRollbackFor = OptimisticLockException.class)
public void updateWithCustomHandling(User user) {
    try {
        user.incrementVersion();
        userRepository.save(user);
    } catch (OptimisticLockException e) {
        log.warn("Concurrent modification for user {}", user.getId());
        throw new CustomBusinessException("Please retry");
    }
}

Self-Invocation Rollback

When self-invocation bypasses the proxy, the transaction never starts. But there is a subtler problem: when you use self-invocation and the inner method throws an exception, the outer method might catch it and swallow it, making the transaction appear to commit when it never started.

@Service
public class AccountService {

    @Transactional
    public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
        try {
            deductFromAccount(fromId, amount);
            addToAccount(toId, amount);
        } catch (InsufficientFundsException e) {
            // Self-invocation means no transaction was ever started
            // The exception was caught, but nothing was rolled back
            // because there was no transaction
            log.warn("Transfer failed: {}", e.getMessage());
        }
    }

    private void deductFromAccount(Long accountId, BigDecimal amount) {
        // No @Transactional here - relies on outer method's transaction
        // But self-invocation means there IS no outer transaction
        Account account = accountRepository.findById(accountId);
        account.withdraw(amount);
        accountRepository.save(account);
    }

    private void addToAccount(Long accountId, BigDecimal amount) {
        Account account = accountRepository.findById(accountId);
        account.deposit(amount);
        accountRepository.save(account);
    }
}

Lock Contention with SERIALIZABLE

Setting isolation = Isolation.SERIALIZABLE forces the database to acquire range locks on all rows read during the transaction. Under concurrent load, this can cause severe lock contention, timeouts, and deadlocks that would not occur with READ_COMMITTED.

// WARNING: This will serialize all reads and writes
// Under concurrent load, expect lock timeouts
@Transactional(isolation = Isolation.SERIALIZABLE)
public List<Inventory> checkAvailability(List<Long> productIds) {
    return inventoryRepository.findByProductIdIn(productIds);
}

Trade-Off Table: Propagation Levels

Level Tx Suspended New Tx Started Rollback Scope Performance Safety
REQUIRED No Only if none Full transaction Low overhead High
REQUIRES_NEW Yes Always Independent Higher (new tx each call) Isolates inner tx
MANDATORY No Error thrown Full transaction Low Enforces caller tx
NESTED No (savepoint) Only if none Savepoint only Moderate Partial rollback
SUPPORTS No No Full transaction if exists Lowest Depends on caller
NOT_SUPPORTED Yes No No transaction No tx overhead No transaction safety
NEVER Error thrown No No transaction No tx overhead Prevents tx

Trade-Off Table: Isolation Levels

Level Dirty Reads Non-Repeatable Reads Phantom Reads Throughput Locking
READ_UNCOMMITTED Yes Yes Yes Highest Minimal
READ_COMMITTED No Yes Yes Good Row-level locks
REPEATABLE_READ No No Yes Moderate Gap locks possible
SERIALIZABLE No No No Lowest Range locks

Observability Checklist

Transaction issues are hard to debug because the problem is timing-dependent and often disappears in test environments. Good observability helps.

  • Enable SQL logging in development to see exactly what queries run inside each transaction
  • Log transaction boundaries at DEBUG level with method entry and commit/rollback outcomes
  • Monitor for long-running transactions that hold locks
  • Track transaction rollback rates per service method
  • Set up alerts for database lock timeout errors
// Enable SQL logging in application.properties
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true

// Custom transaction logging aspect
@Aspect
@Component
public class TransactionLoggingAspect {

    private static final Logger log = LoggerFactory.getLogger(TransactionLoggingAspect.class);

    @Around("@annotation(Transactional)")
    public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        long start = System.currentTimeMillis();
        log.debug("Starting transaction for: {}", methodName);

        try {
            Object result = joinPoint.proceed();
            log.debug("Transaction committed for: {} ({}ms)", methodName,
                    System.currentTimeMillis() - start);
            return result;
        } catch (Throwable t) {
            log.warn("Transaction rolled back for: {} ({}ms) - {}: {}",
                    methodName, System.currentTimeMillis() - start,
                    t.getClass().getSimpleName(), t.getMessage());
            throw t;
        }
    }
}

Spring Boot Actuator provides transaction metrics out of the box if you enable Micrometer. Check the transaction meter binder for metrics like transaction.commit.total, transaction.rollback.total, and transaction.active. For more on observability, see Spring Boot Micrometer Observability and Spring Boot Logging Configuration.

Security Notes

Transaction-level security concerns are easy to overlook but they matter in high-security applications.

Privilege Escalation Through Transactions

If your application uses @PostAuthorize or method-level security, be aware that the transaction boundary can affect whether the security check sees the right data. Security checks run before the transaction starts by default. See Spring Boot Testing Security for patterns around testing security in transactional contexts. If you return a lazy-loaded entity and access its fields after the transaction closes, you get a LazyInitializationException, but worse, you may get data the security check never verified.

Audit Trails Inside Transactions

Audit logs written inside the same transaction as the business operation are rolled back if the business operation fails. This is often not what you want. Use REQUIRES_NEW for audit trails so they commit regardless of the outer transaction fate.

@Service
public class UserManagementService {

    @Transactional
    public void deactivateUser(Long userId) {
        User user = userRepository.findById(userId);
        user.setActive(false);
        userRepository.save(user);
        // REQUIRES_NEW ensures audit commits even if caller rolls back
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeAuditEntry(String action, Long userId, String performedBy) {
        auditLogRepository.save(new AuditLog(action, userId, performedBy));
    }
}

Data Consistency Under Concurrent Access

Without proper isolation, concurrent transactions can read stale or uncommitted data. Applications handling financial transactions, inventory counts, or any data where consistency matters should carefully choose isolation levels and use optimistic locking (@Version) for update-heavy operations.

Security and Compliance Notes

Transaction boundaries affect security in ways that are easy to overlook until something goes wrong.

Privilege Escalation Through Transaction Boundaries

Spring Security method-level annotations like @PostAuthorize check authorization before the transaction starts by default. If you load an entity in a transaction and access its fields after the transaction closes, you may encounter data that the security check never verified. Design transaction boundaries that keep entity access within the security context, or use @Transactional on the controller with read-only security checks.

Audit Logs Rolled Back With Business Operations

Audit logs written inside the same transaction as business operations are rolled back if the business operation fails. This defeats the purpose of auditing. Use REQUIRES_NEW propagation for audit trail writes so they commit regardless of the parent transaction outcome.

Concurrency Considerations

Without proper isolation levels, concurrent transactions can read uncommitted or stale data. Applications handling financial transactions, inventory counts, or any data where consistency matters should combine appropriate isolation levels with optimistic locking (@Version). This detects concurrent modifications and prevents lost updates.

Implied Transaction Scope in Service Layer

Services that call multiple repositories in a single method create implicit transaction boundaries. If access control checks are performed at the controller layer but data is accessed in the service layer, the security context may not match what was verified. Ensure transaction boundaries align with your security boundary assumptions.

Long-Running Transactions and Lock Contention

Transactions that hold locks while waiting for external services (API calls, user input) extend lock hold time and increase contention. Keep transaction boundaries tight around database operations. Move external service calls outside the transaction or use asynchronous patterns that do not hold database locks while waiting.

Production Failure Scenarios

Transaction failures in production are rarely straightforward. The symptoms appear far from the cause, and by the time you notice, multiple operations have been affected. Here are the patterns that show up repeatedly.

Deadlocks Under Concurrent Multi-Entity Updates

When two concurrent transactions update the same rows in opposite order, a deadlock occurs. Transaction A locks entity 1 and waits for entity 2, while transaction B locks entity 2 and waits for entity 1. The database resolves this by rolling back one transaction. Fix this by always acquiring locks in a consistent order across all code paths that update the same entities. Sort entities by ID before updating.

Rollback Not Triggered by Checked Exceptions

@Transactional rolls back by default only on unchecked exceptions (runtime exceptions and errors). Checked exceptions do not trigger rollback by default. If your payment processing throws a checked PaymentException, the transaction commits even though the payment failed. Use rollbackFor = PaymentException.class to specify which checked exceptions should cause rollback.

Silent Commit in Read-Only Transactions

Marking a transaction readOnly = true tells Hibernate to skip dirty checking, but it does not prevent writes. If you call save() inside a read-only transaction, the write usually succeeds silently. The database processes it, no exception is thrown, and your assumption that the transaction was read-only is violated. Use read-only transactions only for genuine reads and verify with integration tests.

SERIALIZABLE Isolation Causing Lock Timeouts

Isolation.SERIALIZABLE acquires range locks that can block other transactions for extended periods. Under concurrent load, this causes lock wait timeouts and rollbacks even when the operations themselves are fast. SERIALIZABLE is almost never the right choice for high-throughput applications. Stick with READ_COMMITTED and use optimistic locking via @Version for specific high-contention scenarios.

Transaction Boundary Too Wide

Wrapping an entire large workflow in a single transaction holds locks for the duration of the workflow. Long-running transactions increase contention, extend rollback windows, and make debugging harder. Break large workflows into smaller transactions with explicit boundaries, using REQUIRES_NEW for operations that must commit independently.

Common Pitfalls / Anti-Patterns

  1. Self-invocation bypasses the proxy — calls between methods in the same class do not use the transaction proxy. Structure code so transactional methods are always called from another Spring bean.

  2. Checked exceptions do not roll back by default — use rollbackFor = YourCheckedException.class explicitly.

  3. readOnly = true does not prevent writes — in Hibernate, writes in a read-only transaction usually succeed silently. Only use it for genuine read-only operations.

  4. REQUIRES_NEW suspends the outer transaction — the inner transaction commits independently. This is correct behavior but can surprise you when the outer transaction rolls back and you expected the inner to roll back too.

  5. SERIALIZABLE causes lock contention — it is almost never the right isolation level for high-throughput applications. Stick with READ_COMMITTED.

  6. Forgetting consistent lock ordering — always lock entities in a deterministic order (by ID) to prevent deadlocks in concurrent multi-entity updates.

  7. Transactions and lazy loading — entities loaded inside a transaction become detached after the transaction closes. Accessing lazy collections outside the transaction triggers LazyInitializationException.

Quick Recap Checklist

Before shipping code that uses @Transactional, run through this:

  • Does this method need a transaction at all, or is it a single repository operation that Spring Data already handles?
  • Is the correct propagation level set? (Default REQUIRED is usually right)
  • Does a checked exception need to trigger rollback? Add rollbackFor
  • Have you tested concurrent execution to check for deadlocks?
  • Is readOnly = true used only on genuinely read-only methods?
  • Have you verified no self-invocation bypasses the proxy?
  • Are entities locked in consistent order when updating multiple rows?
  • Is SERIALIZABLE isolation justified, or can you use READ_COMMITTED?
  • Do audit or log operations use REQUIRES_NEW if they must commit independently?
  • Have you tested that lazy loading works correctly within the transaction boundary?

Interview Questions

1. What happens when a @Transactional method calls another @Transactional method in the same class?

The second call bypasses the Spring proxy entirely. Spring AOP uses proxies, and when you call a method on this, the proxy is never involved. This means the second method runs without any transaction at all, regardless of its own @Transactional annotation. The fix is to inject the bean into itself and call through the injected reference, or restructure so the call comes from another Spring bean.

2. What is the difference between REQUIRED and REQUIRES_NEW propagation?

REQUIRED joins the existing transaction if one is running, or creates a new one if none exists. REQUIRES_NEW always suspends any existing transaction and creates a brand new one. The inner REQUIRES_NEW transaction commits or rolls back completely independently of the outer transaction. If the outer rolls back after an inner REQUIRES_NEW has already committed, that inner commit stays. Use REQUIRES_NEW for operations that must commit regardless of the outer transaction outcome, like audit logs.

3. Why might a transaction marked as readOnly=true still perform writes?

In Hibernate, the readOnly flag tells the session to skip dirty checking and skip flush before commit, but it does not prevent the database from executing writes if you call save() or merge(). The entity manager still processes the operation and sends the SQL to the database. Some JPA providers may throw an exception on write in a read-only transaction, but Hibernate does not. Always verify read-only transactions only contain reads and test that assertion in integration tests.

4. How do you handle partial rollback in a transaction where one step can fail but others must commit?

Use propagation = NESTED with database savepoints. With NESTED, Spring creates a savepoint when entering the nested transaction. If that nested operation fails and throws an exception, you can roll back to the savepoint and continue in the outer transaction. Alternatively, split the operation into separate methods with REQUIRES_NEW propagation for the step that must commit independently, accepting that it commits regardless of the outer transaction fate. The right choice depends on whether the independent step truly needs to survive outer transaction failures or just needs its own rollback control.

5. What isolation level would you choose for a financial transfer operation, and why?

READ_COMMITTED is the practical choice for most financial operations. It prevents dirty reads while keeping lock contention manageable. SERIALIZABLE prevents phantom reads and non-repeatable reads but acquires range locks that severely degrade throughput under concurrent load, and can cause deadlocks. For critical financial operations that read-modify-write the same row, combine READ_COMMITTED isolation with optimistic locking using a @Version field, which detects concurrent modifications and causes one transaction to retry or fail rather than allowing lost updates.

6. What is the default propagation level in Spring transactions?

The default propagation level is REQUIRED. This means the method will join an existing transaction if one is running, or create a new one if no transaction exists. This behavior is defined in the Spring documentation and is the most commonly used propagation level because it handles the majority of use cases without requiring explicit configuration.

7. When would you use SUPPORTS propagation?

SUPPORTS is useful for read-only operations that can work within an existing transaction but do not require one. For example, a reporting query that should participate in a transaction if one exists (for consistency with other reads) but should also be callable from non-transactional contexts like batch jobs where creating a transaction would be unnecessary overhead. It is rarely the right choice for write operations because they need transaction protection.

8. How does NESTED propagation differ from REQUIRES_NEW?

NESTED creates a savepoint within the existing transaction, allowing partial rollback without stopping the entire transaction. REQUIRES_NEW suspends the outer transaction entirely and creates a completely independent new transaction that commits or rolls back on its own. If the outer transaction rolls back after a NESTED savepoint, the nested work is also rolled back (to the savepoint). With REQUIRES_NEW, the inner transaction is already independently committed and is not affected by the outer rollback.

9. What happens when a checked exception is thrown inside a @Transactional method by default?

By default, @Transactional only rolls back on unchecked exceptions (runtime exceptions and errors). Checked exceptions do not trigger rollback, so the transaction commits even if your business logic fails with a checked exception like PaymentException. To change this behavior, use rollbackFor = YourCheckedException.class to explicitly declare which checked exceptions should cause rollback.

10. How does @Transactional interact with Spring Security's method-level annotations?

Spring Security annotations like @PostAuthorize run before the transaction starts by default. This means if you load an entity inside a transaction and access its fields after the transaction closes, you may encounter data that the security check never verified. Additionally, lazy-loaded collections accessed after transaction close will throw LazyInitializationException. Design transaction boundaries to keep entity access within the security context, or use @Transactional on the controller layer for read-only security checks.

11. What is the difference between pessimistic locking and optimistic locking in the context of transactions?

Pessimistic locking (select for update) acquires an exclusive lock on rows immediately, blocking other transactions from reading or modifying those rows until the lock is released. This prevents conflicts but can cause deadlocks and lock contention. Optimistic locking uses a @Version field: when you update, the version is checked and incremented. If another transaction modified the row, the version mismatch throws OptimisticLockException, allowing you to retry or fail gracefully. Optimistic locking scales better under contention but requires retry logic for conflicting updates.

12. How do you handle transactions in a multi-threaded environment?

Each thread needs its own transaction context because Spring's transaction management is thread-local. If you spawn threads inside a @Transactional method, those threads do not automatically inherit the transaction. Use TransactionTemplate or PlatformTransactionManager to programmatically create new transactions in worker threads. Alternatively, use asynchronous methods with @Async which can have their own transaction boundaries. Never rely on thread inheritance for transaction propagation.

13. What are the performance implications of different isolation levels?

READ_UNCOMMITTED has the highest throughput but allows dirty reads. READ_COMMITTED is the practical default with good throughput and prevents dirty reads. REPEATABLE_READ uses gap locks in InnoDB and can cause lock contention. SERIALIZABLE has the lowest throughput because it acquires range locks on all reads, severely limiting concurrent access. The performance difference between READ_COMMITTED and SERIALIZABLE can be an order of magnitude under concurrent load.

14. When would you use TransactionTemplate instead of the @Transactional annotation?

TransactionTemplate is programmatic and offers fine-grained control compared to @Transactional. Use it when you need dynamic transaction boundaries (different propagation based on runtime conditions), when you want to partially commit a transaction (savepoints), when transactions span multiple service calls with conditional logic, or when you need to execute code outside a transaction within a transactional context. It is also useful in non-Spring contexts or when you need to avoid the self-invocation proxy bypass issue.

15. How does Spring Boot configure transaction management by default?

Spring Boot auto-configures a DataSourceTransactionManager (for single data source) or JtaTransactionManager (for multiple data sources or JTA) when it detects Spring JPA or JDBC on the classpath. This transaction manager is automatically used by all @Transactional annotations. Spring Boot also enables transaction management in Spring Data JPA repositories automatically, meaning save(), delete(), and other CRUD operations are already transactional without explicit annotation.

16. Can you place @Transactional on an interface versus a class? What are the implications?

Yes, but it behaves differently based on configuration. With proxyTargetClass = false, Spring uses JDK dynamic proxies and @Transactional on an interface will work. With the default proxyTargetClass = true (CGLIB), the annotation on an interface is ignored and only class-level annotations are processed. Additionally, Spring's TransactionManagementConfigurationSelector may not apply transactions to methods overridden in subclasses. Best practice is to annotate at the class level (or use @EnableTransactionManagement(proxyTargetClass = true)) to ensure consistent behavior. Annotation on private methods is also ignored since proxies cannot intercept private method calls.

17. How do you configure transaction timeouts with @Transactional?

Use the timeout attribute on @Transactional to specify a timeout in seconds. For example, @Transactional(timeout = 30) will cause the transaction to roll back if it runs longer than 30 seconds. This is useful for preventing long-running transactions from holding locks indefinitely. Note that timeout support requires the underlying transaction manager to support it (both DataSourceTransactionManager and JpaTransactionManager do). You can also configure a default timeout globally via TransactionManagementConfigurer in Spring Boot.

18. How does read-only routing work in a primary-replica database setup?

In a primary-replica MySQL or PostgreSQL replicated setup, you want read queries to hit replicas and writes to hit the primary. Spring does not do this automatically with readOnly = true. You need to configure AbstractRoutingDataSource with a TransactionSynchronizationManager.isCurrentTransactionReadOnly() check to route connections. When readOnly = true is set, you can configure the routing datasource to check this flag and select the replica target. Without this custom routing logic, readOnly has no effect on which database server is chosen.

19. What is the difference between @Transactional required and nested propagation in savepoint behavior?

With NESTED, Spring creates a JDBC savepoint when entering the nested transaction method. If the nested operation throws an exception and you catch it, you can call TransactionStatus.setRollbackOnly() to roll back only to the savepoint, allowing the outer transaction to continue. With REQUIRED (default), there is one transaction and any rollback affects the entire transaction. NESTED requires database support for savepoints (all major JDBC drivers support this). If the database does not support savepoints, NESTED falls back to REQUIRED behavior silently.

20. How do you test @Transactional behavior effectively?

Use @Transactional on test classes for integration tests where each test runs in a transaction that rolls back after the test completes, keeping the database clean. However, this masks transaction behavior you actually want to verify. For true transaction testing, use TransactionTestUtils or manually commit/rollback in tests to verify actual commit and rollback behavior. Test self-invocation scenarios by calling the method under test from a separate Spring bean (not internal method calls). Use Testcontainers or an in-memory database (H2) for reproducible transactional tests. Also verify rollback behavior for both checked and unchecked exceptions.

Further Reading

  • Spring Data JPA — How Spring Data repositories handle transactions internally and when to use repository-level vs service-level transaction boundaries
  • Spring Framework Core — The AOP foundation that powers @Transactional proxies and how Spring’s proxy-based transaction management actually works
  • Spring Boot Micrometer Observability — How to expose and monitor transaction metrics like transaction.commit.total and transaction.rollback.total using Spring Boot Actuator and Micrometer
  • Spring Boot Logging Configuration — Configuring structured logging for transaction boundary debugging in production environments
  • Spring Boot Testing Security — Testing method-level security annotations like @PostAuthorize in transactional contexts and avoiding the lazy loading trap
  • Spring Boot Roadmap — The complete learning path for Spring Boot, with all related topics linked in dependency order

Conclusion

Spring’s @Transactional is deceptively simple. The most common mistakes are self-invocation bypassing the proxy, checked exceptions not triggering rollback, and readOnly = true being treated as a write guard when it is not.

A few principles to leave you with. Default to REQUIRED propagation and READ_COMMITTED isolation. Reach for REQUIRES_NEW only when the inner operation must commit independently of the outer transaction. Use NESTED only when you genuinely need savepoint-based partial rollback. Treat SERIALIZABLE as a last resort. Always verify your transaction boundaries in concurrent test scenarios, because race conditions do not show up in sequential unit tests.

If you are working with Spring Data JPA, the repository layer already handles transactions for single operations. See Spring Data JPA for how to work with repositories and their transaction boundaries. Your @Transactional boundaries belong at the service layer where multi-entity business operations live. Pair that with optimistic locking via @Version for read-modify-write scenarios, and you have a transaction strategy that works under concurrent load.

Category

Related Posts

Spring Boot Build Tools: Maven & Gradle

Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.

#spring-boot #spring-boot-roadmap #learning-path

Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow

Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.

#spring-boot #spring-boot-roadmap #learning-path

JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests

Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.

#spring-boot #spring-boot-roadmap #learning-path