# Spring Boot Interview: Transaction Propagation Explained > Master Spring Boot transaction propagation: REQUIRED, REQUIRES_NEW, NESTED and more. 12 interview questions with code examples and common pitfalls. - Published: 2026-03-18 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring boot, transactions, propagation, java, interview - Reading time: 14 min --- Transaction propagation represents a fundamental concept in Spring Boot, regularly evaluated during technical interviews. Understanding how transactions interact between `@Transactional` annotated methods helps avoid subtle production bugs and enables designing robust architectures. > **Preparation Advice** > > Interviewers test the ability to choose the right propagation level based on business context. Being able to explain why REQUIRES_NEW rather than REQUIRED in a specific case makes the difference. ## Transaction Propagation Fundamentals ### Question 1: What is transaction propagation in Spring? Propagation defines the behavior of a transactional method when called within the context of an existing transaction. It answers the question: "What happens when a `@Transactional` method calls another method that is also annotated?" ```java // OrderService.java // Demonstrating the propagation concept @Service public class OrderService { private final PaymentService paymentService; private final OrderRepository orderRepository; // Parent transaction - starts a new transaction @Transactional public void createOrder(OrderRequest request) { // Saves the order in the current transaction Order order = orderRepository.save(new Order(request)); // Calling another @Transactional method // Propagation determines: same transaction or new one? paymentService.processPayment(order.getId(), request.getAmount()); } } @Service public class PaymentService { // Default propagation: REQUIRED // Joins the existing transaction from createOrder() @Transactional public void processPayment(Long orderId, BigDecimal amount) { // Executes in the SAME transaction as createOrder() // If this method fails, the order is also rolled back } } ``` Spring provides seven propagation levels, each suited to specific business needs. The choice directly impacts data consistency and performance. ### Question 2: Describe the behavior of REQUIRED (default propagation) REQUIRED is the default propagation. If a transaction exists, the method joins it. Otherwise, a new transaction is created. This is the most common and intuitive behavior. ```java // UserService.java // REQUIRED: default behavior @Service public class UserService { private final UserRepository userRepository; private final AuditService auditService; @Transactional(propagation = Propagation.REQUIRED) public void updateUser(Long userId, UserUpdateRequest request) { // Starts a transaction if none exists User user = userRepository.findById(userId).orElseThrow(); user.setEmail(request.getEmail()); userRepository.save(user); // Audit joins the same transaction auditService.logUpdate(userId, "EMAIL_CHANGED"); } } @Service public class AuditService { private final AuditLogRepository auditLogRepository; @Transactional(propagation = Propagation.REQUIRED) public void logUpdate(Long userId, String action) { // Joins the transaction from updateUser() // Commit or rollback together auditLogRepository.save(new AuditLog(userId, action, Instant.now())); } } ``` The diagram below illustrates the transactional flow: ```text updateUser() starts TX-1 ├── save(user) → TX-1 └── logUpdate() → joins TX-1 (REQUIRED) └── save(audit) → TX-1 If logUpdate() fails → TX-1 rollback → both user AND audit cancelled ``` ### Question 3: When to use REQUIRES_NEW instead of REQUIRED? REQUIRES_NEW suspends the existing transaction and creates a new independent transaction. Useful when an operation must be committed regardless of the parent transaction's outcome. ```java // PaymentService.java // REQUIRES_NEW: independent transaction @Service public class PaymentService { private final PaymentRepository paymentRepository; private final PaymentAuditService auditService; @Transactional public void processPayment(Long orderId, BigDecimal amount) { Payment payment = new Payment(orderId, amount); paymentRepository.save(payment); // Audit MUST be persisted even if payment fails later auditService.logPaymentAttempt(orderId, amount); // Simulating an error after audit if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new InvalidAmountException("Negative amount"); } } } @Service public class PaymentAuditService { private final PaymentAuditRepository auditRepository; // REQUIRES_NEW: commits independently of parent transaction @Transactional(propagation = Propagation.REQUIRES_NEW) public void logPaymentAttempt(Long orderId, BigDecimal amount) { // New transaction TX-2 created // TX-1 (processPayment) is suspended auditRepository.save(new PaymentAudit(orderId, amount, "ATTEMPTED")); // TX-2 commits here, independently of TX-1 } } ``` The transactional flow with REQUIRES_NEW: ```text processPayment() starts TX-1 ├── save(payment) → TX-1 ├── logPaymentAttempt() → TX-1 SUSPENDED │ └── starts TX-2 → new transaction │ └── save(audit) → TX-2 │ └── TX-2 COMMIT → audit persisted │ └── TX-1 RESUMES └── throw InvalidAmountException └── TX-1 ROLLBACK → payment cancelled, but audit preserved ``` > **Watch out for deadlocks** > > REQUIRES_NEW can cause deadlocks if the new transaction accesses the same resources locked by the suspended transaction. Avoid using REQUIRES_NEW to modify the same tables as the parent transaction. ### Question 4: Explain NESTED propagation and how it differs from REQUIRES_NEW NESTED creates a savepoint within the current transaction. If the method fails, only modifications since the savepoint are rolled back, not the entire parent transaction. ```java // BatchProcessingService.java // NESTED: savepoint within parent transaction @Service public class BatchProcessingService { private final ItemRepository itemRepository; private final ItemProcessor itemProcessor; @Transactional public BatchResult processBatch(List items) { BatchResult result = new BatchResult(); for (Item item : items) { try { // Each item is processed with a savepoint itemProcessor.processItem(item); result.addSuccess(item.getId()); } catch (ProcessingException e) { // Rollback only this item, not the entire batch result.addFailure(item.getId(), e.getMessage()); } } return result; // Commit successful items } } @Service public class ItemProcessor { private final ItemRepository itemRepository; // NESTED: creates a savepoint, partial rollback possible @Transactional(propagation = Propagation.NESTED) public void processItem(Item item) { item.setStatus("PROCESSING"); itemRepository.save(item); // Business validation if (!isValid(item)) { throw new ProcessingException("Invalid item"); // Rollback to savepoint → this item only } item.setStatus("COMPLETED"); itemRepository.save(item); } } ``` Comparison of NESTED vs REQUIRES_NEW: ```text NESTED: ├── Uses a savepoint within parent TX ├── On failure → rollback to savepoint ├── If parent TX rolls back → NESTED also rolls back └── More performant (no new connection) REQUIRES_NEW: ├── Creates a completely independent transaction ├── On failure → rollback child TX only ├── If parent TX rolls back → child TX ALREADY COMMITTED └── Requires a new connection ``` > **NESTED Support** > > NESTED propagation requires JDBC savepoint support. Most modern databases (PostgreSQL, MySQL, Oracle) support this. Verify compatibility before use. ## Advanced Propagation Types ### Question 5: When to use SUPPORTS and NOT_SUPPORTED? SUPPORTS executes within the existing transaction if present, otherwise without a transaction. NOT_SUPPORTED suspends any existing transaction and executes without a transaction. ```java // ReportingService.java // SUPPORTS: optional transaction @Service public class ReportingService { private final ReportRepository reportRepository; // SUPPORTS: works with or without a transaction // Useful for reads that don't need transactional guarantees @Transactional(propagation = Propagation.SUPPORTS) public Report generateReport(Long reportId) { // If called from a @Transactional method → uses its TX // If called directly → no transaction (read-only OK) return reportRepository.generateComplexReport(reportId); } } @Service public class ExternalNotificationService { private final ExternalApiClient apiClient; // NOT_SUPPORTED: never within a transaction // Avoids blocking the TX during a slow external call @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendExternalNotification(String message) { // Parent TX suspended during the call apiClient.send(message); // Potentially slow HTTP call // Parent TX resumes after } } ``` ```java // OrderService.java // Combined usage example @Service public class OrderService { private final OrderRepository orderRepository; private final ReportingService reportingService; private final ExternalNotificationService notificationService; @Transactional public void completeOrder(Long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); order.setStatus("COMPLETED"); orderRepository.save(order); // Report generation in the same TX (SUPPORTS) Report report = reportingService.generateReport(orderId); // External notification OUTSIDE transaction (NOT_SUPPORTED) // Avoids TX timeout if external API is slow notificationService.sendExternalNotification( "Order " + orderId + " completed" ); } } ``` ### Question 6: Explain MANDATORY and NEVER MANDATORY requires an existing transaction and throws an exception otherwise. NEVER requires the absence of a transaction and throws an exception if one exists. ```java // AuditService.java // MANDATORY: must be called from within a transaction @Service public class AuditService { private final AuditRepository auditRepository; // MANDATORY: refuses to execute without an existing transaction // Guarantees that audit is always atomic with the audited operation @Transactional(propagation = Propagation.MANDATORY) public void logCriticalAction(String action, Long entityId) { // If called without a transaction → IllegalTransactionStateException auditRepository.save(new AuditLog(action, entityId, Instant.now())); } } // CacheService.java // NEVER: must never be within a transaction @Service public class CacheService { private final CacheManager cacheManager; // NEVER: cache should not participate in transactions // Avoids inconsistencies between cache and DB after rollback @Transactional(propagation = Propagation.NEVER) public void invalidateCache(String cacheKey) { // If called from a @Transactional → IllegalTransactionStateException cacheManager.getCache("entities").evict(cacheKey); } } ``` ```java // SecurityService.java // Correct usage of MANDATORY @Service public class SecurityService { private final AuditService auditService; @Transactional public void changeUserPassword(Long userId, String newPassword) { // Sensitive operation... updatePassword(userId, newPassword); // Audit MUST be in the same transaction // MANDATORY enforces this architectural constraint auditService.logCriticalAction("PASSWORD_CHANGE", userId); } // ERROR: direct call without transaction public void badUsage() { // Throws IllegalTransactionStateException because no TX auditService.logCriticalAction("TEST", 1L); } } ``` ## Common Interview Pitfalls ### Question 7: Why does @Transactional not work on internal calls? One of the most common pitfalls. Internal method calls (self-invocation) bypass the Spring proxy, disabling transaction management. ```java // BrokenService.java // CLASSIC ERROR: self-invocation @Service public class BrokenService { private final ItemRepository itemRepository; public void processItems(List itemIds) { for (Long id : itemIds) { // TRAP: internal call → bypasses the proxy // @Transactional on processItem() is IGNORED processItem(id); } } @Transactional public void processItem(Long itemId) { // This transaction is NEVER created during internal calls Item item = itemRepository.findById(itemId).orElseThrow(); item.setStatus("PROCESSED"); itemRepository.save(item); } } ``` Solutions to avoid this pitfall: ```java // Solution 1: Self-injection @Service public class FixedServiceWithSelfInjection { private final ItemRepository itemRepository; @Lazy @Autowired private FixedServiceWithSelfInjection self; // Self-injection public void processItems(List itemIds) { for (Long id : itemIds) { // Call through proxy → @Transactional works self.processItem(id); } } @Transactional public void processItem(Long itemId) { // Transaction correctly created Item item = itemRepository.findById(itemId).orElseThrow(); item.setStatus("PROCESSED"); itemRepository.save(item); } } // Solution 2: Separate into two services (recommended) @Service public class ItemOrchestrator { private final ItemProcessor processor; public void processItems(List itemIds) { for (Long id : itemIds) { // Call on another bean → proxy works processor.processItem(id); } } } @Service public class ItemProcessor { private final ItemRepository itemRepository; @Transactional public void processItem(Long itemId) { // Transaction correctly managed Item item = itemRepository.findById(itemId).orElseThrow(); item.setStatus("PROCESSED"); itemRepository.save(item); } } ``` ### Question 8: How to handle exceptions and rollback? By default, Spring only rolls back on RuntimeException and Error. Checked exceptions do NOT trigger automatic rollback. ```java // TransactionRollbackDemo.java // Rollback behavior based on exception type @Service public class TransactionRollbackDemo { private final OrderRepository orderRepository; // RuntimeException → automatic ROLLBACK @Transactional public void methodWithRuntimeException() { orderRepository.save(new Order()); throw new RuntimeException("Error"); // ROLLBACK } // Checked Exception → NO rollback by default @Transactional public void methodWithCheckedException() throws IOException { orderRepository.save(new Order()); throw new IOException("File error"); // COMMITS anyway! } // Force rollback on checked exception @Transactional(rollbackFor = IOException.class) public void methodWithRollbackFor() throws IOException { orderRepository.save(new Order()); throw new IOException("Error"); // ROLLBACK thanks to rollbackFor } // Exclude a RuntimeException from rollback @Transactional(noRollbackFor = BusinessException.class) public void methodWithNoRollbackFor() { orderRepository.save(new Order()); throw new BusinessException("Warning"); // COMMITS despite exception } } ``` Recommended configuration for business cases: ```java // BaseTransactionalService.java // Consistent transactional configuration @Service public abstract class BaseTransactionalService { // Rollback on all exceptions (checked and unchecked) @Transactional(rollbackFor = Exception.class) protected void executeInTransaction(Runnable operation) { operation.run(); } } // PaymentService.java @Service public class PaymentService extends BaseTransactionalService { private final PaymentRepository paymentRepository; @Transactional( rollbackFor = Exception.class, noRollbackFor = InsufficientFundsException.class ) public PaymentResult processPayment(PaymentRequest request) { Payment payment = new Payment(request); paymentRepository.save(payment); if (request.getAmount().compareTo(getBalance()) > 0) { // Does NOT rollback - we want to keep the attempt record throw new InsufficientFundsException("Insufficient balance"); } return new PaymentResult(payment.getId(), "SUCCESS"); } } ``` ### Question 9: How does transaction isolation work with propagation? Isolation and propagation are complementary. Isolation determines data visibility between concurrent transactions. ```java // IsolationDemo.java // Combining isolation + propagation @Service public class IsolationDemo { private final AccountRepository accountRepository; // READ_COMMITTED: sees data committed by other transactions @Transactional( isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED ) public BigDecimal getAccountBalance(Long accountId) { // May see different values if re-read during the transaction return accountRepository.findById(accountId) .map(Account::getBalance) .orElse(BigDecimal.ZERO); } // REPEATABLE_READ: guarantees the same read during the transaction @Transactional( isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED ) public void transferWithConsistentRead(Long fromId, Long toId, BigDecimal amount) { Account from = accountRepository.findById(fromId).orElseThrow(); Account to = accountRepository.findById(toId).orElseThrow(); // Even if another transaction modifies 'from' in the meantime, // we always see the initial value (snapshot) from.debit(amount); to.credit(amount); accountRepository.save(from); accountRepository.save(to); } // SERIALIZABLE: maximum isolation, no concurrency @Transactional( isolation = Isolation.SERIALIZABLE, propagation = Propagation.REQUIRES_NEW ) public void criticalOperation(Long accountId) { // Blocks any other transaction on this data // Use sparingly - performance impact Account account = accountRepository.findById(accountId).orElseThrow(); account.performCriticalUpdate(); accountRepository.save(account); } } ``` Summary table of isolation levels: ```text | Isolation | Dirty Read | Non-Repeatable | Phantom | |------------------|------------|----------------|---------| | READ_UNCOMMITTED | Possible | Possible | Possible| | READ_COMMITTED | No | Possible | Possible| | REPEATABLE_READ | No | No | Possible| | SERIALIZABLE | No | No | No | ``` > **Performance Impact** > > The stricter the isolation, the more performance can be impacted by locks. SERIALIZABLE can cause significant contention in high-traffic production environments. ## Advanced Patterns ### Question 10: How to implement the "transactional outbox" pattern? The outbox pattern guarantees consistency between database modifications and message/event sending, even in case of failure. ```java // OutboxService.java // Transactional Outbox Pattern @Service public class OutboxService { private final OutboxRepository outboxRepository; // Saves the event in the same transaction as the business entity @Transactional(propagation = Propagation.MANDATORY) public void saveEvent(String aggregateType, Long aggregateId, String eventType, String payload) { OutboxEvent event = OutboxEvent.builder() .aggregateType(aggregateType) .aggregateId(aggregateId) .eventType(eventType) .payload(payload) .status("PENDING") .createdAt(Instant.now()) .build(); outboxRepository.save(event); } } @Service public class OrderService { private final OrderRepository orderRepository; private final OutboxService outboxService; @Transactional public Order createOrder(OrderRequest request) { // Create the order Order order = new Order(request); orderRepository.save(order); // Outbox event in the SAME transaction (MANDATORY) // If commit succeeds → both are persisted // If rollback → neither is persisted outboxService.saveEvent( "ORDER", order.getId(), "ORDER_CREATED", toJson(new OrderCreatedEvent(order)) ); return order; } } // OutboxPublisher.java // Separate process that publishes events @Service public class OutboxPublisher { private final OutboxRepository outboxRepository; private final MessageBroker messageBroker; // Independent transaction for each publication @Scheduled(fixedDelay = 1000) @Transactional(propagation = Propagation.REQUIRES_NEW) public void publishPendingEvents() { List events = outboxRepository .findByStatusOrderByCreatedAt("PENDING"); for (OutboxEvent event : events) { try { messageBroker.publish(event.getEventType(), event.getPayload()); event.setStatus("PUBLISHED"); event.setPublishedAt(Instant.now()); } catch (Exception e) { event.setStatus("FAILED"); event.setError(e.getMessage()); } outboxRepository.save(event); } } } ``` ### Question 11: How to test different propagation behaviors? Propagation tests require special attention to verify expected transactional behavior. ```java // TransactionPropagationTest.java // Testing propagation behaviors @SpringBootTest @Transactional class TransactionPropagationTest { @Autowired private OrderService orderService; @Autowired private PaymentService paymentService; @Autowired private TestEntityManager entityManager; @Test void required_shouldShareTransaction() { // Given Order order = orderService.createOrder(new OrderRequest()); // When - payment in the same transaction (REQUIRED) paymentService.processPayment(order.getId(), BigDecimal.TEN); // Then - both are visible before commit entityManager.flush(); assertThat(entityManager.find(Order.class, order.getId())).isNotNull(); assertThat(entityManager.find(Payment.class, order.getId())).isNotNull(); } @Test void requiresNew_shouldCommitIndependently() { // Given Long orderId = null; try { // When - audit in REQUIRES_NEW orderId = orderService.createOrderWithAudit(new OrderRequest()); throw new RuntimeException("Simulated failure after audit"); } catch (RuntimeException e) { // Main transaction rolls back } // Then - audit (REQUIRES_NEW) is still committed assertThat(findAuditLog(orderId)).isNotNull(); // But the order is rolled back assertThat(findOrder(orderId)).isNull(); } @Test void mandatory_shouldThrowWithoutTransaction() { // Given - no active transaction // When/Then - must throw an exception assertThatThrownBy(() -> auditService.logCriticalAction("TEST", 1L)) .isInstanceOf(IllegalTransactionStateException.class) .hasMessageContaining("No existing transaction"); } } ``` ```java // PropagationIntegrationTest.java // Integration test with actual rollback and commit @SpringBootTest class PropagationIntegrationTest { @Autowired private OrderService orderService; @Autowired private OrderRepository orderRepository; @Autowired private AuditLogRepository auditLogRepository; @Test void nested_shouldRollbackOnlyNestedOnFailure() { // Given int initialCount = orderRepository.findAll().size(); // When - batch processing with NESTED BatchResult result = orderService.processBatchWithNested( List.of(validItem(), invalidItem(), validItem()) ); // Then - only valid items are persisted assertThat(result.getSuccessCount()).isEqualTo(2); assertThat(result.getFailureCount()).isEqualTo(1); assertThat(orderRepository.findAll().size()).isEqualTo(initialCount + 2); } } ``` ### Question 12: What are the best practices for transaction configuration? Consistent transactional configuration at the project level avoids surprises and facilitates maintenance. ```java // TransactionConfig.java // Centralized transaction configuration @Configuration @EnableTransactionManagement public class TransactionConfig { // Default timeout for all transactions @Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager tm = new DataSourceTransactionManager(dataSource); tm.setDefaultTimeout(30); // 30 seconds max per transaction return tm; } } // BaseService.java // Default annotations for services @Service @Transactional( readOnly = true, // Read-only by default rollbackFor = Exception.class // Rollback on any exception ) public abstract class BaseService { // Read methods inherit readOnly = true } // OrderService.java // Service with consistent configuration @Service public class OrderService extends BaseService { private final OrderRepository orderRepository; // Inherits readOnly = true public Order findById(Long id) { return orderRepository.findById(id).orElseThrow(); } // Override for writes @Transactional(readOnly = false) public Order createOrder(OrderRequest request) { return orderRepository.save(new Order(request)); } // Explicit configuration for critical cases @Transactional( readOnly = false, propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE, timeout = 10 ) public void criticalOperation(Long orderId) { // Operation with maximum isolation and short timeout } } ``` Best practices checklist: ```text Transaction Configuration - Checklist ✅ readOnly = true by default, explicit override for writes ✅ rollbackFor = Exception.class to include checked exceptions ✅ Appropriate timeout based on operation type ✅ Avoid internal calls (@Transactional ignored) ✅ REQUIRES_NEW only when independent commit is necessary ✅ MANDATORY to guarantee transactional context ✅ Explicit tests for rollback behaviors ✅ Monitor long-running transactions ✅ Document non-standard propagation choices ``` ## Conclusion Transaction propagation is a fundamental concept evaluated in Spring Boot interviews. Key points to remember: **Common propagation types:** - ✅ REQUIRED (default): joins or creates a transaction - ✅ REQUIRES_NEW: independent transaction, separate commit - ✅ NESTED: savepoint for partial rollback - ✅ MANDATORY: requires an existing transaction **Pitfalls to avoid:** - ✅ Self-invocation: bypasses proxy, @Transactional ignored - ✅ Checked exceptions: no rollback by default - ✅ REQUIRES_NEW on same data: deadlock risk - ✅ Missing timeout: transactions blocked indefinitely **Best practices:** - ✅ readOnly = true by default - ✅ rollbackFor = Exception.class systematically - ✅ Separate services to avoid self-invocation - ✅ Explicitly test rollback behaviors Mastering transaction propagation demonstrates deep understanding of Spring and data management. These concepts are essential for designing robust applications and passing technical interviews successfully. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/spring-boot/spring-boot-transaction-propagation-interview