The best way to use the Spring Transactional annotation

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!


In this article, I’m going to show you the best way to use the Spring Transactional annotation.

This is one of the best practices I applied when developing RevoGain, a web application that allows you to calculate the gains you realized while trading stocks, commodities, or crypto using Revolut.

Spring Transactional annotation

Right from the 1.0 version, Spring offered support for AOP-based transaction management that allowed developers to define the transaction boundaries declaratively. I know this because I was reading its manual in the autumn of 2004:

Very soon after, in the 1.2 version, Spring added support for the @Transactional annotation, which made it even easier to configure the transaction boundaries of business units of work.

The @Transactional annotation provides the following attributes:

  • value and transactionManager – these attributes can be used to provide a TransactionManager reference to be used when handling the transaction for the annotated block
  • propagation – defines how the transaction boundaries propagate to other methods that will be called either directly or indirectly from within the annotated block. The default propagation is REQUIRED and means that a transaction is started if no transaction is already available. Otherwise, the in-progress transaction is going to be used by the current running method.
  • timeout and timeoutString – define the maximum number of seconds the current method is allowed to run before throwing a TransactionTimedOutException
  • readOnly – defines if the current transaction is read-only or read-write.
  • rollbackFor and rollbackForClassName – define one or more Throwable classes for which the current transaction will be rolled back. By default, a transaction is rolled back if a RuntimException or an Error is thrown, but not if it throws a checked Exception.
  • noRollbackFor and noRollbackForClassName – define one or more Throwable classes for which the current transaction will not be rolled back. Normally, you’d use these attributes for one or more RuntimException classes for which you don’t want to roll back a given transaction.

What layer does the Spring Transactional annotation belong to?

The @Transactional annotation belongs to the Service layer because it is the Service layer’s responsibility to define the transaction boundaries.

Don’t use it in the Web layer because this can increase the database transaction response time and make it more difficult to provide the right error message for a given database transaction error (e.g., consistency, deadlock, lock acquisition, optimistic locking).

The DAO (Data Access Object) or Repository layer requires an application-level transaction, but this transaction should propagate from the Service layer.

The best way to use the Spring Transactional annotation

In the Service layer, you can have both database-related and non-database-related services. If a given business use case needs to mix them, like when it has to parse a given statement, build a report, and save some results to the database, it’s best if the database transaction is started as late as possible.

For this reason, you could have a non-transactional gateway service, like the following RevolutStatementService:

public class RevolutStatementService {

    @Transactional(propagation = Propagation.NEVER)
    public TradeGainReport processRevolutStocksStatement(
            MultipartFile inputFile, 
            ReportGenerationSettings reportGenerationSettings) {
        return processRevolutStatement(
    private TradeGainReport processRevolutStatement(
            MultipartFile inputFile,
            ReportGenerationSettings reportGenerationSettings,
            StatementParser statementParser
    ) {
        ReportType reportType = reportGenerationSettings.getReportType();
        String statementFileName = inputFile.getOriginalFilename();
        long statementFileSize = inputFile.getSize();

        StatementOperationModel statementModel = statementParser.parse(
        int statementChecksum = statementModel.getStatementChecksum();
        TradeGainReport report = generateReport(statementModel);

        )) {

        return report;

The processRevolutStocksStatement method is non-transactional, and, for this reason, we can use the Propagation.NEVER strategy to make sure that this method is never ever called from an active transaction.

The statementParser.parse and the generateReport method are, therefore, executed in a non-transactional context as we don’t want to acquire a database connection and hold it necessarily when we only have to execute application-level processing.

Only the operationService.addStatementReportOperation requires to execute in a transactional context, and for this reason, the addStatementReportOperation uses the @Transactional annotation:

@Transactional(readOnly = true)
public class OperationService {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public boolean addStatementReportOperation(
        String statementFileName,
        long statementFileSize,
        int statementChecksum,
        OperationType reportType) {

Note that the addStatementReportOperation overrides the default isolation level and specifies that this method is executed in a SERIALIZABLE database transaction.

Another thing worth noting is that the class is annotated with @Transactional(readOnly = true), meaning that, by default, all service methods will use this setting and execute in a read-only transaction unless the method overrides the transactional settings using its own @Transactional definition.

For transactional services, it’s good practice to set the readOnly attribute to true at the class level and override it on a per-method basis for the service methods that need to write to the database.

For instance, the UserService uses the same pattern:

@Transactional(readOnly = true)
public class UserService implements UserDetailsService {

    public UserDetails loadUserByUsername(String username) 
        throws UsernameNotFoundException {
    public void createUser(User user) {

The loadUserByUsername uses a read-only transaction, and since we are using Hibernate, Spring performs some read-only optimizations as well.

On the other hand, the createUser has to write to the database. Hence, it overrides the readOnly attribute value with the default setting given by the @Transactional annotation, which is readOnly=false, therefore making the transaction read-write.

Another great advantage of splitting read-write and read-only methods is that we can route them to different database nodes, as explained in this article.

Read-write and read-only transaction routing with Spring

This way, we can scale read-only traffic by increasing the number of replica nodes.

Awesome, right?

I'm running an online workshop on the 11th of October about High-Performance SQL.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.


The Spring Transactional annotation is very handy when it comes to defining the transaction boundaries of business methods.

While the default attribute values were properly chosen, it’s good practice to provide both class-level and method-level settings to split use cases between non-transactional, transactional, read-only, and read-write use cases.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.