How To Use @Transactional In Spring Properly


When working with Spring projects, probably you use ORM instead of raw JDBC as I demonstrated in the last post. With Spring AOP, you can simply use the @Transactional annotation to handle transactions in your app.

Let’s learn how to use the @Transactional annotation properly in Spring applications.

Basic @Transactional Usage

Let’s simulate the example with Alice and Bob. Initially, they both have 1000 credits in their account. For table schema, please refer to the previous posts in the series.

I’m going to create a model to map to the users in the database:

package com.datmt.springdata.springdatajpatransactional;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

public class BankUser {
    private Long id;

    private String name;

    private Long balance;

Next, let’s create a service bean that handles the credit transfer process:

package com.datmt.springdata.springdatajpatransactional;

import org.springframework.jdbc.core.RowMapper;
import org.springframework.transaction.annotation.Transactional;

import java.sql.ResultSet;
import java.sql.SQLException;

public class BankTransferService extends JdbcDaoSupport {

    public void transfer(String from, String to, Long amount) {
        withdraw(from, amount);
        deposit(to, amount);

    public void resetBalance() {
       getJdbcTemplate().update("UPDATE bank_user SET balance = 1000") ;
    private BankUser getUser(String username) {
        return getJdbcTemplate().query("SELECT * FROM bank_user WHERE name = '" + username + "'", new RowMapper<BankUser>() {
            public BankUser mapRow(ResultSet rs, int rowNum) throws SQLException {
                var bankUser = new BankUser();
                return bankUser;

    private void withdraw(String user, Long amount) {
        var bankUser = getUser(user);
        var newBalance = bankUser.getBalance() - amount;
        getJdbcTemplate().update("UPDATE bank_user SET balance = ? WHERE name = ?", newBalance, user);

    private void deposit(String user, Long amount) {
        var bankUser = getUser(user);
        var newBalance = bankUser.getBalance() + amount;
        getJdbcTemplate().update("UPDATE bank_user SET balance = ? WHERE name = ?", newBalance, user);

The logic in this class is quite simple. There are four methods:

  • withdraw
  • deposit
  • resetBalance
  • transfer

Their names are self-explanatory.

Let’s start transferring money from Alice to Bob in case there isn’t any problem:

public class SpringDataJpaTransactionalApplication {

    public static void main(String[] args) {

        var appContext = new AnnotationConfigApplicationContext(AppConfig.class);
        var bankTransferService = appContext.getBean(BankTransferService.class);
        bankTransferService.transfer("Alice", "Bob", 100L);



After running this code, if you check the database, you will see that the new balances reflect the data correctly:

New balance in database
New balance in the database

Now, let’s simulate an exception after the withdrawal method. For example, the following code will throw an exception in the deposit method when the amount is greater than 99:

    private void deposit(String user, Long amount) {
        if (amount >= 100)
            throw new RuntimeException("I cannot get that much!");
        var bankUser = getUser(user);
        var newBalance = bankUser.getBalance() + amount;

        getJdbcTemplate().update("UPDATE bank_user SET balance = ? WHERE name = ?", newBalance, user);

Now, if you run the code in the main method again, for sure the exception will be thrown. However, after the method is executed, both Alice and Bob still have 1000 credits:

Transaction rolled back on exception, data stays consistent
Transaction rolled back on exception, data stays consistent

Now you see the basic functionality of the @Transactional annotation. Let’s dig deeper into this.

Understand the Propagation Transaction Attributes

Transaction propagation specifies the way transactions are handled in the situation of one transactional method calls another transactional method.

Imagine this situation, Bob configured his banking application to whenever he receives any credit, that amount would be split in three: 25% to his saving account and 25% to his investing account, and 50% to his main account (the account that Alice sends to).

So, in the case of Alice sending Bob 100 credits, things get a bit complex like this:

Transaction within transaction diagram

So, in the database, there should be two more bank users representing Bob’s savings and investing accounts. Let’s insert the manually:

Add Bob's investing and saving accounts
Add Bob’s investing and saving accounts

In the bank transfer service class, I’m going to reuse the transfer method:

    public void sendAndSplitToOtherAccounts(String senderName, String recipientName, Long amount,
                                            String recipientSavingAccount, String recipientInvestingAccount) {
        transfer(senderName, recipientName, amount);
        transfer(recipientName, recipientSavingAccount, amount / 4);
        transfer(recipientName, recipientInvestingAccount, amount / 4);

Here, you can see that this method uses a fixed ratio as mentioned above (50%, 25%, 25%). It’s not flexible and in real life, you may not implement it like this. However, for the demonstration’s purpose, this would work.

You can notice that this new method is also annotated with the @Transactional annotation, just like the transfer method. When the first transfer method inside this new method is called,

  • should a transaction already exist?
  • Should the transfer method initiate a new transaction?
  • Should the transfer method throw an exception if there is an ongoing transaction?

You can manage all that with the transaction propagation property.

There are seven possible values. Here is a brief introduction to all of them:

REQUIREDThis is the default attribute. In our case, the transfer method would run with an existing transaction if available. If there is no transaction available, it should create a new transaction and run inside that.
REQUIRES_NEWThe method should start a new transaction regardless if any transaction exists. The existing transaction is suspended.
SUPPORTSIf there is no existing transaction, no transaction will be created. However, if there is a transaction exists, the method should join that transaction.
NOT_SUPPORTEDThe method should not run within a transaction. An exception will be thrown if that’s the case.
MANDATORYThe method requires an existing transaction. If not, an exception will be thrown
NEVERThis is the opposite of MANDATORY
NESTEDUses a single physical transaction with multiple savepoints that it can roll back to
Types of transaction propagation

In the case above, if there is no explicit propagation defined, the REQUIRED option is used. That means the method sendAndSplitToOtherAccounts will start a transaction and all three transfer methods will use the transaction started by the outer method.

Problems with concurrent transactions

When multiple transactions operate on a shared data set, data can be corrupted if the isolation level is not handled correctly.

Here are the problems that may occur with concurrent transactions on the shared data sets:

Dirty readGiven two transactions T1 and T2 both try to update Bob’s balance. T2 comes first, modify the balance but haven’t committed. T1 read that uncommitted balance and does its calculation. Later T2 experience an exception and rolled back. The value that T1 reads and uses for calculation is no longer valid.
Nonrepeatable readStill with T1 and T2. First, T1 read the balance from the database. After that, T2 comes and modifies the balance. If T1 reads the balance again, it will see a different value
Phantom readT1 selects rows that have a balance greater than a certain amount. T2 comes an inserts new rows that satisfy T1’s select condition. If T1 runs the select query again, it will get more rows than the first read.
Issues with concurrent transactions

These problems can all be solved by setting the right level of isolation. There are options that solve some problems. There is an option that can solve all. What’s the trade-off? The answer is performance.

Let’s find out more about the isolation levels and how they solve these problems in the next section.

Mitigate concurrent transactions issues with isolation levels

The isolation level affects the number of concurrent users who can access a piece of data at a point in time. A lower level permits more access with the risk of data inconsistency/corruption. A higher level guarantees a higher level of data consistency at the cost of performance.

The isolation levels are managed by the database system, not by ORMs or frameworks.

Here are the four isolation levels defined by ANSI/ISO:

Isolation LevelDescriptionLevel
SerializableHighest isolation level. None of the problems mentioned above are possible. Worst performance though.
Repeatable readsPhantom-read is possible. Other problems not possible
Read committedDirty read not possible, other problems are possible
Read uncommittedAll problems are possible
Isolation and concurrent problem possibility

To get more details about the isolation levels, I recommend you check this write-up on Wikipedia.

Spring framework supports all isolation levels.

To specify a different isolation level other than DEFAULT, you can do like this:

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void doSomeTransaction(){
//.Some code


In this post, I’ve shown you how the @Transactional annotation work in the Spring framework. You can use the annotation with default values (without specifying anything) and it may serve you in most cases. However, in some cases, you may need to specify the propagation attribute and/or the isolation level to achieve your business needs.

As always, the code is available on Github here.

Leave a Comment