Java Spring Boot @Transactional
Some tricky, non-obvious aspects of behavior.
Spring Boot contains 1 important annotation @Transactional used mostly in a service.
0. First lets check if transaction ever started in the function:
println(TransactionSynchronizationManager.isActualTransactionActive())- In case of a read operation, in a service, you need to mention it. Such an approach speeds up your application. Method write implements checking and validations (so it takes time to track changes).
@Service
@Transactional(readOnly = true) // Default for all methods
public class ReadOnlyService {
@Transactional(readOnly = true) // not necessary to mentioned about it
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User absent"));
}
@Transactional // Override: allow writes
public User createUser(User user) {
User user = userRepository.save(user);
scheduledJobLaucherNotifierService.notifyJobFinished(UPDATER);
return user;
}
}2. Explicit flush/clear in long loops. The audit() method starts a new transaction, independent from the outer one, because of Propagation.REQUIRES_NEW.
The idea is as follows (good for file imports, batch jobs inside a request):
Spring opens a read-only transaction; it uses FlushMode.MANUAL.
Because it has propagation = REQUIRES_NEW, Spring suspends the outer (read-only) transaction.
A new write-enabled transaction is started for audit.
Since this one is not marked read-only, it uses FlushMode.AUTO.
Hibernate will flush automatically on commit.
Changes in audit() will be committed independently of the outer transaction.
@Service
@Transactional
public class ReportService {
private final AuditService auditService;
public ReportService(AuditService auditService) {
this.auditService = auditService;
}
public void generateReport(Long reportId) {
auditService.auditStart(reportId); // committed immediately
try {
processLargeDataset(reportId);
auditService.auditSuccess(reportId);
} catch (Exception e) {
auditService.auditFailure(reportId, e.getMessage());
throw e; // main tx rolls back
}
}
private void processLargeDataset(Long reportId) {
// heavy DB work, streaming, batching, etc.
}
}and the AuditService itself:
@Service
public class AuditService {
private final AuditRepository auditRepository;
public AuditService(AuditRepository auditRepository) {
this.auditRepository = auditRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditStart(Long reportId) {
auditRepository.save(
new AuditLog(reportId, "START")
);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditSuccess(Long reportId) {
auditRepository.save(
new AuditLog(reportId, "SUCCESS")
);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditFailure(Long reportId, String reason) {
auditRepository.save(
new AuditLog(reportId, "FAILURE: " + reason)
);
}
}Conclusion of propagation: this must be committed, no matter what happens outside. Good uses for metrics, error tracking, and notifications.
2a. In case of write the log: Logs should not participate in the main transaction, otherwise rollback removes them.
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun logRaw(...)2b. When REQUIRED makes sense.
@Service
public class OrderService {
@Transactional(propagation = Propagation.REQUIRED)
public void placeOrder(Order order) {
orderRepo.save(entity);
auditService.logOrderEvent(order);
}
}
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderEvent(Order order) {
// This logs even if the outer transaction rolls back
}
}3. Transactional does NOT work on private methods.
Spring’s @Transactional works via AOP proxies, and private methods are never proxied, so the annotation is silently ignored.
@Service
public class OrderService {
public void placeOrder() {
savePrivateOrder(); // internal call, no proxy involved
savePublicOrder(); // self-invocation, no proxy
}
@Transactional
private void savePrivateOrder() {
// still not transactional
paymentRepository.save(new Payment());
}
@Transactional
public void savePublicOrder() {
// still not transactional
orderRepository.save(new Order());
}
}- Spring creates a proxy around
OrderService - The proxy only intercepts public method calls
placeOrder()callssaveOrder()inside the same class- The call bypasses the proxy
- Result: no transaction started
Solution:
@Service
public class OrderService {
@Transactional
public void placeOrder() {
orderRepository.save(new Order());
paymentRepository.save(new Payment());
}
}OrderService to OrderTransactionalServicegoes through the proxy- Proxy intercepts the public method, transaction is applied correctly.
@Transactionalmust be on a public method- Method must be called from outside the bean
4. Calling the Transactional method from the same class — the proxy is skipped, so the transaction never starts.
Get Aleksei Jegorov’s stories in your inbox
Join Medium for free to get updates from this writer.
Spring’s @Transactional works via AOP proxies.
- Spring creates a proxy around your bean
- The proxy intercepts external method calls
- It starts/commits / rolls back the transaction
- We make self invocation
placeOrder() calls saveOrder() directly on this, not through the proxy.
So the proxy is bypassed, and no transaction is created.
Solution: move the transaction method to another bean.
@Service
public class OrderService {
private final OrderTxService txService;
public OrderService(OrderTxService txService) {
this.txService = txService;
}
public void placeOrder() {
txService.saveOrder(); // goes through proxy
}
}
@Service
public class OrderTxService {
@Transactional
public void saveOrder() { // transaction active
orderRepository.save(new Order());
}
}Transactional only works when the call goes through a Spring proxy.
5. In case of unchecked exceptions, we can expect a rollback.
rollbackForexceptions that should trigger rollbacknoRollbackForexceptions that should NOT trigger rollback
@Transactional(
rollbackFor = RuntimeException.class,
noRollbackFor = IllegalArgumentException.class
)Default rollback rules:
- Unchecked exceptions (
RuntimeException,Error) do ROLLBACK - Checked exceptions (
Exception,IOException, etc.) do COMMIT
// without rollbackFor
// transaction will commit, not roll back — because IOException is checked
@Transactional
public void saveData() throws IOException {
repository.save(new User(...));
throw new IOException("Disk error");
}
// with rollbackFor
// Now Spring rolls back the transaction when IOException is thrown.
@Transactional(rollbackFor = IOException.class)
public void saveData() throws IOException {
repository.save(new User(...));
throw new IOException("Disk error");
}noRollbackFor is just the opposite of rollbackFor:
@Transactional(
rollbackFor = {IOException.class, CustomCheckedException.class},
noRollbackFor={IllegalArgumentException.class, ValidationException.class}
)
public void processOrder() throws IOException {
if (invalidInput) throw new ValidationException();
if (ioFailure) throw new IOException();
}6. Add a simple timeout to prevent long-running transactions.
add to application.properties
spring.jpa.properties.hibernate.jdbc.timeout=30
or
@Transactional(readOnly = true, timeout = 10)
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
}If the transaction takes longer than 30 seconds, it should roll back automatically.
7. Caused by: jakarta.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread — cannot reliably process ‘remove’ call
This exception is classic when using Spring Data JPA with delete calls outside of a transaction. By example, in a unit test:
// When Spring Data tries to remove an entity,
// it fails because no transaction is active.
@BeforeEach
fun cleanDatabase() {
myRepository.deleteByClientReference(clientRef)
}
// Solution ensures a transaction is active during deletes.
@Autowired
lateinit var transactionTemplate: TransactionTemplate
@BeforeEach
fun cleanDatabase() {
transactionTemplate.execute {
myRepository.deleteByClientReference(clientRef)
}
}Conclusion
// For service calculation:
@Transactional(propagation = Propagation.REQUIRED)
fun evaluate(client: String): ResultResponse
// For webhook processing:
@Transactional(propagation = Propagation.REQUIRES_NEW)
// so webhook is isolated.










