An Interest In:
Web News this Week
- April 23, 2024
- April 22, 2024
- April 21, 2024
- April 20, 2024
- April 19, 2024
- April 18, 2024
- April 17, 2024
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 dont 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, lets deep dive into @Transactional
annotation.
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
.
@Servicepublic 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")); }}
And here is a simple unit test.
@SpringBootTest@AutoConfigureTestDatabaseclass 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()); }}
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 :0Actual :2
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. Thats 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 annotations attribute rollbackFor
.
For example, both of these cases are perfectly valid.
@Servicepublic 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")); }}
@SpringBootTest@AutoConfigureTestDatabaseclass 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()); }}
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
.
@Servicepublic 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"); } }}
Lets add validation to our PersonService
.
@Service@Slf4jpublic 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")); }}
If validation does not pass, we create a new person with the default name.
Ok, now we need to test it.
@SpringBootTest@AutoConfigureTestDatabaseclass 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()); }}
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
Thats 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.
The default @Transactional
propagation is REQUIRED
. It means that the new transaction is created if its missing. And if its 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. Thats 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.
Whats the solution? There are several ones. For example, we can add noRollbackFor
attribute to PersonValidateService
.
@Servicepublic 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"); } }}
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.
@Servicepublic 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"); } }}
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 dont 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.
Lets rewrite the very first example of the article with java.lang.Exception
in Kotlin.
@Serviceclass 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")) }}
@SpringBootTest@AutoConfigureTestDatabaseinternal 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()) }}
The test fails just like in Java.
expected: <0> but was: <2>Expected :0Actual :2
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
Thats 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!
Original Link: https://dev.to/kirekov/spring-data-transactional-caveats-19di
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To