Transaction Propagation, Isolation, and the Read-Only Optimization
Propagation levels are not configuration preferences. They are instructions to AbstractPlatformTransactionManager about what to do with the JDBC Connection. Every level maps to specific operations on DataSource, Connection, and in some cases, JDBC Savepoint. This section traces each level from the annotation to the database driver.
REQUIRED: The Default
Propagation.REQUIRED is the default when you write @Transactional without specifying propagation.
Behavior:
- If no transaction exists: get a
Connectionfrom theDataSource, callConnection.setAutoCommit(false), bind theConnectiontoTransactionSynchronizationManager - If a transaction already exists: join it by reusing the same
Connectionfrom the ThreadLocal
There is no savepoint created for REQUIRED. The inner method runs in the same transaction. If the inner method throws, the transaction is marked as rollback-only. The outer method cannot commit. Attempting to commit a rollback-only transaction throws UnexpectedRollbackException.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
@Transactional // REQUIRED by default
public OrderResult createOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
// Also REQUIRED: joins the same transaction
inventoryService.reserveStock(order);
return OrderResult.success(order.getId());
}
}
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryRepository inventoryRepository;
@Transactional // REQUIRED: joins the caller's transaction
public void reserveStock(Order order) {
for (OrderLine line : order.getLines()) {
Inventory inv = inventoryRepository.findBySkuForUpdate(line.getSku());
if (inv.getAvailable() < line.getQuantity()) {
throw new InsufficientStockException(line.getSku());
}
inv.reserve(line.getQuantity());
inventoryRepository.save(inv);
}
}
}
Both createOrder() and reserveStock() share one Connection, one transaction. If reserveStock() throws InsufficientStockException (a RuntimeException), the transaction is marked rollback-only. The order is not persisted.
REQUIRES_NEW: Independent Transaction
Propagation.REQUIRES_NEW always creates a new transaction, regardless of whether one exists.
At the JDBC level:
TransactionSynchronizationManagerunbinds the currentConnectionHolderand stores it in aSuspendedResourcesHolder- A new
Connectionis obtained from theDataSource Connection.setAutoCommit(false)on the new connection- The new
ConnectionHolderis bound toTransactionSynchronizationManager - After the inner method completes (commit or rollback), the suspended resources are restored
This means two connections are held simultaneously. The outer transaction’s connection is suspended, not returned to the pool. The inner transaction gets a second connection.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryDeductionService inventoryDeductionService;
@Transactional
public OrderResult createOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
// Separate transaction: commits even if order processing fails later
inventoryDeductionService.deductInventory(order);
// If this throws after deductInventory committed, inventory is deducted
// but order is rolled back. Handle with compensation logic.
notificationService.sendOrderConfirmation(order);
return OrderResult.success(order.getId());
}
}
@Service
@RequiredArgsConstructor
public class InventoryDeductionService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductInventory(Order order) {
// Runs in its own transaction, its own Connection
// Commits independently of the caller
for (OrderLine line : order.getLines()) {
inventoryRepository.deduct(line.getSku(), line.getQuantity());
}
}
}
The consequence: if sendOrderConfirmation() throws after deductInventory() has committed, inventory is deducted but the order is rolled back. You now have a data inconsistency. REQUIRES_NEW is not free. Use it only when you need independent commit/rollback semantics and you have a compensation strategy for partial failures.
Connection pool sizing must account for REQUIRES_NEW. If you have 20 concurrent requests and each uses REQUIRES_NEW inside a REQUIRED transaction, you need at least 40 connections. Size your pool accordingly or you will deadlock.
NESTED: Savepoints
Propagation.NESTED creates a JDBC Savepoint within the existing transaction:
Connection.setSavepoint("SAVEPOINT_1")
If the nested method rolls back, the rollback goes to the savepoint, not to the start of the outer transaction. The outer transaction can still commit. If the outer transaction rolls back, the savepoint is also rolled back (it is part of the same transaction).
@Transactional
public OrderResult createOrder(TenantId tenantId, OrderRequest request) {
Order order = Order.create(tenantId, request);
orderRepository.save(order);
try {
loyaltyService.awardPoints(order); // NESTED
} catch (LoyaltyException e) {
// Loyalty points failed, but savepoint is rolled back
// The order save is still intact
log.warn("Loyalty points not awarded for order {}", order.getId());
}
return OrderResult.success(order.getId());
}
@Service
public class LoyaltyService {
@Transactional(propagation = Propagation.NESTED)
public void awardPoints(Order order) {
// Runs within a savepoint
// Rollback here does not rollback the outer transaction
loyaltyRepository.addPoints(order.getTenantId(), order.calculatePoints());
}
}
Unlike REQUIRES_NEW, NESTED uses a single Connection. No second connection from the pool. No suspension. The savepoint is a marker within the same transaction.
Limitation: not all JDBC drivers support savepoints. Most modern drivers (PostgreSQL, MySQL 5.0+, Oracle, H2) do. Check DatabaseMetaData.supportsSavepoints(). JPA EntityManager does not expose savepoints directly. NESTED works with DataSourceTransactionManager out of the box, but JpaTransactionManager requires setNestedTransactionAllowed(true) and the underlying JDBC driver must support it.
The Minor Propagation Levels
SUPPORTS: If a transaction exists, join it. If none exists, execute non-transactionally. Use case: read methods that should participate in a transaction if one is active but do not need one otherwise.
NOT_SUPPORTED: If a transaction exists, suspend it. Execute non-transactionally. The ConnectionHolder is unbound from TransactionSynchronizationManager. The method gets a separate, auto-commit connection.
MANDATORY: If a transaction exists, join it. If none exists, throw IllegalTransactionStateException. Use this to enforce that a method must be called from within a transaction. Guards against accidental direct invocation.
NEVER: If a transaction exists, throw IllegalTransactionStateException. The inverse of MANDATORY. Use for methods that must not run inside a transaction, such as operations that take exclusive locks for long durations.
readOnly=true: What It Actually Does
@Transactional(readOnly = true) does two things:
1. Hibernate flush mode set to MANUAL
When JpaTransactionManager begins a read-only transaction, it calls Session.setDefaultReadOnly(true) and sets the flush mode to FlushMode.MANUAL. This disables dirty checking entirely. Hibernate does not compare entity snapshots at commit time. For a query returning 10,000 entities, this eliminates 10,000 snapshot comparisons. The performance difference is measurable.
2. JDBC connection hint
Connection.setReadOnly(true) is called on the JDBC connection. This is a hint, not an enforcement. What happens with this hint depends on the driver:
- PostgreSQL: The driver may route the query to a read replica if configured with a multi-host connection string
- MySQL: Similar replica routing with the Connector/J driver’s
readOnlysupport - Oracle: The hint is generally ignored
- HikariCP: Can be configured to route read-only connections to a dedicated pool pointing at a replica
The hint does not prevent writes at the JDBC level. It is advisory. But Hibernate’s MANUAL flush mode does prevent writes at the ORM level.
// BROKEN: @Transactional(readOnly=true) on a method that writes
@Service
@RequiredArgsConstructor
public class TenantReportService {
private final TenantRepository tenantRepository;
private final ReportCacheRepository reportCacheRepository;
@Transactional(readOnly = true)
public TenantReport generateReport(TenantId tenantId) {
Tenant tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
TenantReport report = reportGenerator.generate(tenant);
// This will fail: flush mode is MANUAL
// Hibernate will not flush the persist to the database
reportCacheRepository.save(ReportCache.of(tenantId, report));
return report;
}
}
The save() call enqueues an INSERT in the persistence context, but because flush mode is MANUAL, Hibernate never sends the SQL to the database. The report cache is never persisted. No exception is thrown. The data silently disappears. If you call EntityManager.flush() explicitly, Spring throws TransactionRequiredException because the transaction is marked read-only at the JPA level.
// CORRECT: separate read and write paths
@Service
@RequiredArgsConstructor
public class TenantReportService {
private final TenantRepository tenantRepository;
private final ReportCacheService reportCacheService;
@Transactional(readOnly = true)
public TenantReport generateReport(TenantId tenantId) {
Tenant tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
return reportGenerator.generate(tenant);
}
}
@Service
@RequiredArgsConstructor
public class ReportCacheService {
private final ReportCacheRepository reportCacheRepository;
@Transactional // read-write, REQUIRED
public void cacheReport(TenantId tenantId, TenantReport report) {
reportCacheRepository.save(ReportCache.of(tenantId, report));
}
}
Reads use readOnly = true for the performance benefit. Writes use a standard read-write transaction. The caller orchestrates both:
TenantReport report = reportService.generateReport(tenantId);
reportCacheService.cacheReport(tenantId, report);
Isolation Levels
@Transactional(isolation = Isolation.REPEATABLE_READ) maps directly to:
Connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ)
The mapping:
Isolation.DEFAULTuses the database default (usuallyREAD_COMMITTED)Isolation.READ_UNCOMMITTEDmaps toConnection.TRANSACTION_READ_UNCOMMITTEDIsolation.READ_COMMITTEDmaps toConnection.TRANSACTION_READ_COMMITTEDIsolation.REPEATABLE_READmaps toConnection.TRANSACTION_REPEATABLE_READIsolation.SERIALIZABLEmaps toConnection.TRANSACTION_SERIALIZABLE
The isolation level is set on the Connection when the transaction begins. It is reset to the default when the connection is returned to the pool. In a REQUIRES_NEW scenario, the inner transaction can have a different isolation level than the outer one because it uses a different Connection.
One pitfall: if you set isolation = Isolation.SERIALIZABLE on a REQUIRED method and a transaction already exists with READ_COMMITTED, Spring does not change the isolation level. The existing transaction’s isolation level wins. You get no error, no warning. The isolation level attribute is silently ignored when joining an existing transaction. This is documented but rarely read.
To enforce isolation, use REQUIRES_NEW so the method always gets its own Connection with the specified isolation level. Or use MANDATORY and document that callers must start a transaction with the correct isolation.
Summary Table
| Propagation | Existing Tx? | Behavior | Connections |
|---|---|---|---|
| REQUIRED | Yes | Join | 1 |
| REQUIRED | No | Create | 1 |
| REQUIRES_NEW | Yes | Suspend + Create | 2 |
| REQUIRES_NEW | No | Create | 1 |
| NESTED | Yes | Savepoint | 1 |
| NESTED | No | Create (like REQUIRED) | 1 |
| SUPPORTS | Yes | Join | 1 |
| SUPPORTS | No | Non-transactional | 1 (auto-commit) |
| NOT_SUPPORTED | Yes | Suspend | 2 |
| NOT_SUPPORTED | No | Non-transactional | 1 (auto-commit) |
| MANDATORY | Yes | Join | 1 |
| MANDATORY | No | Throw exception | 0 |
| NEVER | Yes | Throw exception | 0 |
| NEVER | No | Non-transactional | 1 (auto-commit) |
The connection count column is the key. Size your connection pool based on the maximum concurrent connections your propagation patterns require, not the maximum concurrent requests.