Sitemap
Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Java Spring Boot @Transactional

--

Some tricky, non-obvious aspects of behavior.

Press enter or click to view image in full size

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())
  1. 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() calls saveOrder() 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 OrderTransactionalService goes through the proxy
  • Proxy intercepts the public method, transaction is applied correctly.
  • @Transactional must 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.

  • rollbackFor exceptions that should trigger rollback
  • noRollbackFor exceptions 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.

--

--

Aleksei Jegorov
Aleksei Jegorov

Written by Aleksei Jegorov

Java, Kotlin, T-SQL, Angular2, Typescript, ReactJs - developer in Estonia, Tallinn. “Code is like humor. When you have to explain it, it’s bad.” - Cory House

Dev Genius
Dev Genius

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development