DEV Community

Semyon Kirekov
Semyon Kirekov

Posted on

Spring Data — Transactional Caveats

Spring is the most popular Java framework. It has lots of out-of-box solutions for web, security, caching, and data access. Spring Data especially makes the life of a developer much easier. We don’t have to worry about database connections and transaction management. The framework does the job. But the fact that it hides some important details from us may lead to hard-tracking bugs and issues. So, let’s deep dive into @Transactional annotation.

Cover image

Default Rollback Behaviour

Assume that we have a simple service method that creates 3 users during one transaction. If something goes wrong, it throws java.lang.Exception.

@Service
public class PersonService {
  @Autowired
  private PersonRepository personRepository;

  @Transactional
  public void addPeople(String name) throws Exception {
    personRepository.saveAndFlush(new Person("Jack", "Brown"));
    personRepository.saveAndFlush(new Person("Julia", "Green"));
    if (name == null) {
      throw new Exception("name cannot be null");
    }
    personRepository.saveAndFlush(new Person(name, "Purple"));
  }
}
Enter fullscreen mode Exit fullscreen mode

And here is a simple unit test.

@SpringBootTest
@AutoConfigureTestDatabase
class PersonServiceTest {
  @Autowired
  private PersonService personService;
  @Autowired
  private PersonRepository personRepository;

  @BeforeEach
  void beforeEach() {
    personRepository.deleteAll();
  }

  @Test
  void shouldRollbackTransactionIfNameIsNull() {
    assertThrows(Exception.class, () -> personService.addPeople(null));
    assertEquals(0, personRepository.count());
  }
}
Enter fullscreen mode Exit fullscreen mode

Do you think the test will pass or not? Logic tells us that Spring should roll back the transaction due to an exception. So, personRepository.count() ought to return 0, right? Well, not exactly.

expected: <0> but was: <2>
Expected :0
Actual   :2
Enter fullscreen mode Exit fullscreen mode

That requires some explanations. By default, Spring rolls back transaction only if an unchecked exception occurs. The checked ones are treated like restorable. In our case, Spring performs commit instead of rollback. That’s why personRepository.count() returns 2.

The easiest way to fix it is to replace a checked exception with an unchecked one (e.g., NullPointerException). Or else we can use the annotation’s attribute rollbackFor.

For example, both of these cases are perfectly valid.

@Service
public class PersonService {
  @Autowired
  private PersonRepository personRepository;

  @Transactional(rollbackFor = Exception.class)
  public void addPeopleWithCheckedException(String name) throws Exception {
    addPeople(name, Exception::new);
  }

  @Transactional
  public void addPeopleWithNullPointerException(String name) {
    addPeople(name, NullPointerException::new);
  }

  private <T extends Exception> void addPeople(String name, Supplier<? extends T> exceptionSupplier) throws T {
    personRepository.saveAndFlush(new Person("Jack", "Brown"));
    personRepository.saveAndFlush(new Person("Julia", "Green"));
    if (name == null) {
      throw exceptionSupplier.get();
    }
    personRepository.saveAndFlush(new Person(name, "Purple"));
  }
}
Enter fullscreen mode Exit fullscreen mode
@SpringBootTest
@AutoConfigureTestDatabase
class PersonServiceTest {
  @Autowired
  private PersonService personService;
  @Autowired
  private PersonRepository personRepository;

  @BeforeEach
  void beforeEach() {
    personRepository.deleteAll();
  }

  @Test
  void testThrowsExceptionAndRollback() {
    assertThrows(Exception.class, () -> personService.addPeopleWithCheckedException(null));
    assertEquals(0, personRepository.count());
  }

  @Test
  void testThrowsNullPointerExceptionAndRollback() {
    assertThrows(NullPointerException.class, () -> personService.addPeopleWithNullPointerException(null));
    assertEquals(0, personRepository.count());
  }

}
Enter fullscreen mode Exit fullscreen mode

Tests execution results

Rollback on Exception Suppressing

Not all exceptions have to be propagated. Sometimes it is acceptable to catch it and log information about it.

Suppose that we have another transactional service that checks whether the person can be created with the given name. If it is not, it throws IllegalArgumentException.

@Service
public class PersonValidateService {
  @Autowired
  private PersonRepository personRepository;

  @Transactional
  public void validateName(String name) {
    if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) {
      throw new IllegalArgumentException("name is forbidden");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s add validation to our PersonService.


@Service
@Slf4j
public class PersonService {
  @Autowired
  private PersonRepository personRepository;
  @Autowired
  private PersonValidateService personValidateService;

  @Transactional
  public void addPeople(String name) {
    personRepository.saveAndFlush(new Person("Jack", "Brown"));
    personRepository.saveAndFlush(new Person("Julia", "Green"));
    String resultName = name;
    try {
      personValidateService.validateName(name);
    }
    catch (IllegalArgumentException e) {
      log.error("name is not allowed. Using default one");
      resultName = "DefaultName";
    }
    personRepository.saveAndFlush(new Person(resultName, "Purple"));
  }
}
Enter fullscreen mode Exit fullscreen mode

If validation does not pass, we create a new person with the default name.

Ok, now we need to test it.


@SpringBootTest
@AutoConfigureTestDatabase
class PersonServiceTest {
  @Autowired
  private PersonService personService;
  @Autowired
  private PersonRepository personRepository;

  @BeforeEach
  void beforeEach() {
    personRepository.deleteAll();
  }

  @Test
  void shouldCreatePersonWithDefaultName() {
    assertDoesNotThrow(() -> personService.addPeople(null));
    Optional<Person> defaultPerson = personRepository.findByFirstName("DefaultName");
    assertTrue(defaultPerson.isPresent());
  }

}
Enter fullscreen mode Exit fullscreen mode

But the result is rather unexpected.

Unexpected exception thrown: org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
Enter fullscreen mode Exit fullscreen mode

That’s weird. The exception has been suppressed. Why did Spring roll back the transaction? Firstly, we need to understand the @Transactional management approach.

Internally Spring uses the aspect-oriented programming pattern. Skipping the complex details, the idea behind it is to wrap an object with the proxy that performs the required operations (in our case, transaction management). So, when we inject the service that has any @Transactional method, actually Spring puts the proxy.

Here is the workflow for the defined addPeople method.

Spring Transactional Management

The default @Transactional propagation is REQUIRED. It means that the new transaction is created if it’s missing. And if it’s present already, the current one is supported. So, the whole request is being executed within a single transaction.

Anyway, there is a caveat. If the RuntimeException throws out of the transactional proxy, Spring marks the current transaction as rollback only. That’s exactly what happened in our case. PersonValidateService.validateName throws IllegalArgumentException. Transactional proxy tracks it and sets on the rollback flag. Later executions during the transaction have no effect because they ought to be rolled back in the end.

What’s the solution? There are several ones. For example, we can add noRollbackFor attribute to PersonValidateService.

@Service
public class PersonValidateService {
  @Autowired
  private PersonRepository personRepository;

  @Transactional(noRollbackFor = IllegalArgumentException.class)
  public void validateName(String name) {
    if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) {
      throw new IllegalArgumentException("name is forbidden");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Another approach is to change the transaction propagation to REQUIRES_NEW. In this case, PersonValidateService.validateName will be executed in a separate transaction. So, the parent one will not be rollbacked.

@Service
public class PersonValidateService {
  @Autowired
  private PersonRepository personRepository;

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void validateName(String name) {
    if (name == null || name.isBlank() || personRepository.existsByFirstName(name)) {
      throw new IllegalArgumentException("name is forbidden");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Possible Kotlin Issues

Kotlin has many common things with Java. But exception management is not the case.

Kotlin eliminated the idea of checked and unchecked exceptions. Basically, any exception in the language is unchecked because we don’t need to specify throws SomeException in the method declaration. The pros and cons of this decision should be a topic for another story. But now I want to show you the problems it may bring with Spring Data usage.

Let’s rewrite the very first example of the article with java.lang.Exception in Kotlin.

@Service
class PersonService(
    @Autowired
    private val personRepository: PersonRepository
) {
    @Transactional
    fun addPeople(name: String?) {
        personRepository.saveAndFlush(Person("Jack", "Brown"))
        personRepository.saveAndFlush(Person("Julia", "Green"))
        if (name == null) {
            throw Exception("name cannot be null")
        }
        personRepository.saveAndFlush(Person(name, "Purple"))
    }
}
Enter fullscreen mode Exit fullscreen mode
@SpringBootTest
@AutoConfigureTestDatabase
internal class PersonServiceTest {
    @Autowired
    lateinit var personRepository: PersonRepository

    @Autowired
    lateinit var personService: PersonService

    @BeforeEach
    fun beforeEach() {
        personRepository.deleteAll()
    }

    @Test
    fun `should rollback transaction if name is null`() {
        assertThrows(Exception::class.java) { personService.addPeople(null) }
        assertEquals(0, personRepository.count())
    }
}
Enter fullscreen mode Exit fullscreen mode

The test fails just like in Java.

expected: <0> but was: <2>
Expected :0
Actual   :2
Enter fullscreen mode Exit fullscreen mode

There are no surprises. Spring manages transactions in the same way in either Java or Kotlin. But in Java, we cannot execute a method that throws java.lang.Exception without taking care of it. Kotlin allows it. That may bring unexpected bugs, so, you should pay extra attention to such cases.

Conclusion

That’s all I wanted to tell you about Spring @Transactional annotation. If you have any questions or suggestions, please leave your comments down below. Thanks for reading!

Top comments (3)

Collapse
 
dimosn profile image
Dimos

Hi Semyon,
Thanks a lot for the article, it’s very demonstrative and useful.
One question: will removal of @Transactional annotation from the method validateName also solve considered problem?

Collapse
 
kirekov profile image
Semyon Kirekov

@dimosn
Hi Dimos,
Thank you! In this case, the removal of @Transactional will definitely help to resolve the issue. Because the transaction is started anyway on addPeople method call.

But you should take into account that the validateName method might be complex and consists of several database calls. Also, there is a chance that someone might call the method from non-transactional context. This would lead to multiple transactions during one business operation which can cause the inconsistent final result.

Therefore, knowing the rollback behavior patterns is useful anyways :)

Collapse
 
dimosn profile image
Dimos • Edited

Yes, sure, Semyon, here the content of the validateName method is simplified for clarity, I can imagine :) Thanks!