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.

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.
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?"
// 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.
// 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:
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 cancelledQuestion 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.
// 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:
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 preservedREQUIRES_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.
// NESTED: savepoint within parent transaction
@Service
public class BatchProcessingService {
private final ItemRepository itemRepository;
private final ItemProcessor itemProcessor;
@Transactional
public BatchResult processBatch(List<Item> 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:
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 connectionNESTED 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.
// 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
}
}// 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.
// 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);
}
}// 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);
}
}Ready to ace your Spring Boot interviews?
Practice with our interactive simulators, flashcards, and technical tests.
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.
// CLASSIC ERROR: self-invocation
@Service
public class BrokenService {
private final ItemRepository itemRepository;
public void processItems(List<Long> 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:
// Solution 1: Self-injection
@Service
public class FixedServiceWithSelfInjection {
private final ItemRepository itemRepository;
@Lazy
@Autowired
private FixedServiceWithSelfInjection self; // Self-injection
public void processItems(List<Long> 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<Long> 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.
// 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:
// 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.
// 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:
| 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 |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.
// 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<OutboxEvent> 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.
// 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");
}
}// 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.
// 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:
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 choicesReady to ace your Spring Boot interviews?
Practice with our interactive simulators, flashcards, and technical tests.
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.
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

30 Spring Boot Interview Questions: Complete Guide for Java Developers
Prepare for your Spring Boot interviews with these 30 essential questions covering auto-configuration, starters, Spring Data JPA, security, and testing.

Spring Modulith: Modular Monolith Architecture Explained
Learn Spring Modulith to build modular monoliths in Java. Architecture, modules, async events and testing with Spring Boot 3 code examples.

Spring Batch 5 Interview: Partitioning, Chunks and Fault Tolerance
Ace your Spring Batch 5 interviews: 15 essential questions on partitioning, chunk-oriented processing, and fault tolerance with Java 21 code examples.