Middle-starter-pack by spring data
Who is the author of this article anyway?
My name is Starakozhev Denis,
at the time of writing, I am the current IT lead of a cross-functional team in fintech (including 7 back-end developers),
I have 5 years of very active commercial coding experience and solution design (java back-end),
We have experience both in working with ancient legacy and in projects from scratch.
(I managed to suffer through a lot of interesting things: business logic, integrations, multithreading, algorithms, testing, etc.)
Who is this article for?
In this article, I will consider several non-obvious points that any spring-data-jpa user will encounter sooner or later.
The article is not an exhaustive guide and is aimed at those who have at least once installed the Transactional annotation in a spring application and were sincerely surprised when the expectation did not match the result.
I also think that any developer at middle level and above should be familiar with this issue.
Magic does not exist
As practice shows, many developers treat annotations in code as if they were magic spells, without even thinking about why these “spells” work at all.
I can’t say 100% that some kind of Narnia does not exist, but I declare with all responsibility that there is definitely no magic in development, there is only code that someone wrote and which always works exactly as it is written.
The code always works the way we wrote it (or we didn’t), if we think that the code doesn’t work that way, it means we don’t know/don’t take something into account.
reservation
There are, of course, factors that cause the code to work differently than intended, for example cosmic rays, but this probability can be neglected.
It’s also worth mentioning multithreading artifacts, but that’s a completely different story (maybe I’ll write an article about that later).
To the point
Next, let's look at a few examples.
pom
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.5.RELEASE</version>
<relativePath/>
</parent>
<groupId>org.example</groupId>
<artifactId>transactional-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
main
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
basic components
package org.example.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.UUID;
@Data
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "example")
public class ExampleEntity {
@Id
@GeneratedValue
private UUID id;
private String value;
}
package org.example.data;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface ExampleEntityRepository extends CrudRepository<ExampleEntity, UUID> {
Optional<ExampleEntity> findByValue(String value);
}
package org.example.data;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ExampleService {
private final ExampleEntityRepository exampleEntityRepository;
public void save(String value) {
exampleEntityRepository.save(ExampleEntity.builder()
.value(value)
.build());
}
public ExampleEntity get(UUID id) {
return exampleEntityRepository.findById(id)
.orElseThrow();
}
}
Case 1: classic
An example that almost everyone received at an interview.
What happens to the transaction when the saveAll() method is called?
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
@RequiredArgsConstructor
public class FirstCase {
private final ExampleService exampleService;
public void saveAll() {
saveFirst();
saveSecond();
}
@Transactional
public void saveFirst() {
exampleService.save("first");
}
@Transactional
public void saveSecond() {
exampleService.save("second");
}
}
Any sane person who is aware of how aspects work in spring would guess that there will be no transaction
because
Transactions, like all other aspects, work through decoration.
In a decorator (a classic pattern for expanding the functionality of a component whose code we cannot or do not want to change).
The conceptual implementation looks something like this:
package org.example.decorator;
import org.springframework.stereotype.Service;
public class DecoratorExample {
/**
* Наш сервис, который мы придумали
*/
@Service
public static class SomeService {
public Object doSome() {
return new Object();
}
}
/**
* Декоратор, который в случае с аспектами "из коробки" генерируется в рантайме
*/
public static class SomeServiceDecorator extends SomeService {
private final SomeService originService;
public SomeServiceDecorator(SomeService originService) {
this.originService = originService;
}
@Override
public Object doSome() {
before();
try {
Object returnValue = originService.doSome(); // вызов оригинального метода
after(returnValue);
return returnValue;
} catch (Exception e) {
return handle(e);
}
}
private void before() {
// какая-то логика до вызова метода, например по открытию транзакции
}
private void after(Object returnValue) {
// какая-то логика после вызова метода, например по коммиту транзакции
}
private Object handle(Exception e) {
// какая-то логика обработки исключний, например по откату транзакции
throw new RuntimeException(e);
}
}
}
Accordingly, for methods that are marked with “magic” annotations, such logic will be added, and for those that are not marked, simple delegation will remain
package org.example.decorator;
import org.springframework.stereotype.Service;
public class DelegateDecoratorExample {
/**
* Наш сервис который мы придумали
*/
@Service
public static class SomeService {
public Object doSome() {
return new Object();
}
}
/**
* Декоратор, который в случае с аспектами "из коробки" генерируется в рантайме
*/
public static class SomeDelegateServiceDecorator extends SomeService {
private final SomeService originService;
public SomeDelegateServiceDecorator(SomeService originService) {
this.originService = originService;
}
@Override
public Object doSome() {
return originService.doSome(); // вызов оригинального метода
}
}
}
When forming the context, ExampleService will be wrapped in a decorator generated on the fly, and since the decorator inherits (or implements the same interfaces as the original class, none of the consumers will guess about it unless they explicitly check (detailed analysis from the guru).
If we return to the question, we will get approximately the following method call scheme
![](https://habrastorage.org/getpro/habr/upload_files/e6d/a7f/25f/e6da7f25f52a7d9fb95ccab765f9e8ca.png)
Case 2: JPA and persistenceContext
As practice shows, a large percentage of developers, working with JPA in the form of hibernate, covered by spring-data, are not even aware of the existence of persistenceContext (or, at best, they heard that it exists at the phenomenon level).
A detailed analysis of its features will be too cumbersome for this article (for these purposes it is better to read a book, for example, “Java Persistence API and Hibernate”).
I'll skim the surface:
persistenceContext – this is a very highly pumped cache, which stores everything that is received from the database within the transaction, and also accumulates changes initiated within this very transaction.
Context conceptually has 2 sets of data
1. arrays of objects representing what is currently visible in the database
2. entity – which are created from arrays of objects from p1 (or new ones – manually in code), it is with them that we interact in our logic.
The next key phenomenon is flush contextwithin which the difference between the contents of the database (p1) and the results of our work (p2) is calculated, and only this diff is sent to the database in the form of queries.
The question is when?
1. mandatory during transaction commit.
2. at any time when Heber deems it necessary (for example, before some specific selection in the database).
Thus, calling the save method of the repository does not at all guarantee that the request will be sent immediately, and even that the request will be sent after the completion of the method marked as Transactional, because it is likely that the transaction is opened at another level according to the stactrace, and the commit will occur there. (example below)
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class FlushCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
private final ExampleService exampleService;
@Transactional
public void firstDoSame() {
secondService.secondDoSome(); // после выполнения это строки нет абсолютно никакой гарантии что мы увидем изменения в БД
exampleService.save("firstValue");
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
@Transactional
public void secondDoSome() {
exampleService.save("secondValue");
}
}
}
But here's the method save at the repository guarantees to us, this is what he will return to us entity associated with the context.
You can often see code like this
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleEntity;
import org.example.data.ExampleEntityRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class PersistEntityCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
@Transactional
public void doSome() {
ExampleEntity entity = secondService.getOrCreateEntity("value");
entity.setValue("newValue");
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleEntityRepository exampleEntityRepository;
@Transactional
public ExampleEntity getOrCreateEntity(String value) { // классический подход реализации upsert
return exampleEntityRepository.findByValue(value)
.orElseGet(() -> ExampleEntity.builder()
.value(value)
.build());
}
}
}
Here we see that the person who wrote FirstService is aware of the optionality of calling the save() method, since it is enough to simply modify the entity and the changes will be sent to the database using autoFlush when the transaction is committed.
But this will not happen if a new entity is saved, because the persistenceContext knows nothing about it (and it finds out just when save is called).
In my opinion, there is a jamb in the implementation of SecondService, since the implementation is not unambiguous,
but at the same time, calling the save method in FirstService will not hurt either.
The question of how to design a system and divide responsibilities between components in such a way that it is obvious what we are working with (with a full-fledged entity or just with a POJO) is not a clear-cut question, but we must always obtain guarantees that the expected state is equal to reality.
Case 3: readOnly attribute
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class ReadOnlyCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
@Transactional(readOnly = true)
public void doSome() {
secondService.secondDoSome();
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
@Transactional
public void secondDoSome() {
exampleService.save("someValue");
}
}
}
A transaction is opened when the first method on the stack is called according to the settings specified in the annotation attached to it, and most attributes on “nested” methods have no meaning.
Thus, in this picture, when calling the FirstService.doSome() method, data within line 30 will not be sent to the database (within the framework of the SecondService.secondDoSome() implementation).
All this is quite logical and obvious, but sometimes I come across an attempt to secure my code in this form
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class ReadOnlyCase2 {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
@Transactional
public void doSome() {
secondService.secondDoSome();
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
private final OftenChangeSomeService oftenChangeSomeService;
/**
* метод, в котором важно, чтобы в БД не было запросов на запись
*/
@Transactional(readOnly = true)
public void secondDoSome() {
oftenChangeSomeService.logic();
exampleService.save("someValue");
}
}
/**
* Сервис, который разрабатывается другой коммандой и может измениться в любой момент
*/
@Service
public static class OftenChangeSomeService {
public Object logic() {
// нет гарантии что тут не появистся логики на запись
return new Object();
}
}
}
In this example, we see that readOnly in line 32 (above the SecondService.secondDoSome() method) does not give us a 100% guarantee if SecondService is someone else's dependency.
In general, the use of such attributes reduces the transparency of the code, and can come out sideways, so there must be very good reasons for their use.
+ you definitely need to test this, because for different TransactionManager implementations the behavior may differ (or not be supported at all)
Case 4: exception handling
I often see code like this, which basically starts and works fine for quite a long time.
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class ExceptionCase {
@Service
@RequiredArgsConstructor
public static class FirstService {
private final SecondService secondService;
private final ExampleService exampleService;
@Transactional
public void firstDoSame() {
exampleService.save("firstValue");
try {
secondService.secondDoSame(); // если не сохранится, то с нас не убудет
} catch (Exception e) {
// какая-то логика с полгощением исключения
}
}
}
@Service
@RequiredArgsConstructor
public static class SecondService {
private final ExampleService exampleService;
@Transactional
public void secondDoSame() {
exampleService.save("secondValue");
}
}
}
But then it turns out that there is not enough data in the database, and there are similar “unknown errors” in the logs
![](https://habrastorage.org/getpro/habr/upload_files/950/ac8/339/950ac83392b733fd47acbfc2326773ad.png)
The point is that when an exception “flies” through a method marked as Transactional, the status of the transaction changes in the aspect, and it can no longer be committed.
Of course, there is the noRollbackFor attribute, but this is not a panacea, because completely unexpected exceptions can be thrown from dependencies.
The method actually works, but you need to approach it with knowledge, and also take into account that not only you will use this code.
Also, the presence of noRollbackFor indicates a blurring of the components’ areas of responsibility or an attempt to use try/catch where if/else is more appropriate.
Case 5: Transactional and Controller
I also often see attempts to make similar code
package org.example.first;
import lombok.RequiredArgsConstructor;
import org.example.data.ExampleEntity;
import org.example.data.ExampleEntityRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
public class ExampleController {
@RestController
@RequiredArgsConstructor
public static class ExampleRestController {
private final ExampleEntityRepository exampleEntityRepository;
@GetMapping
public Iterable<ExampleEntity> getAll() {
return exampleEntityRepository.findAll();
}
}
@Controller
@RequiredArgsConstructor
public static class ExampleMvcController {
private final ExampleEntityRepository exampleEntityRepository;
@GetMapping
public String getAll(Model model) {
model.addAttribute(exampleEntityRepository.findAll());
return "page.html";
}
}
}
I won’t go on and on about what a bad idea it is to pull an entity to the controller level, I’ll just mention why you can catch LazyInitializationException.
The persistenceContext's lifecycle is directly related to the transaction.
Transaction opened – context has been createdand each transaction has its own context (hello multithreading).
Transaction closed – the context is dead along with the Heber session.
And since the conversion of the return value (substitution of values from the model) occurs outside the transaction boundaries (in the depths of the DispatcherServlet), by this moment all our entities turn into pumpkin regular POJOs, and attempts to load lazy fields result in exceptions.
A small BLITZ
1. JPA does not support propagation NESTED (let’s just imagine what a hassle it would be to organize a safePoint for the persistenceContext, given that links to an entity from it can be anywhere)
2. you need to have very strong reasons for using propagation REQUIRES_NEW, as well as a deep understanding of hardware and steel genitals (hello multithreading, transaction isolation and data consistency)
3. making friends with lombok and hibernate is also a different story (hello StackOverflow and LazyInit)
4. when calling the save() method, the repository is guaranteed to receive the current entity, but there is no guarantee that we will receive the same object that was passed as a parameter
Conclusion
There is no magic, these are all implementation features (and often even part of the specification).
We need to study the tools we use and we can't take anyone's word for it, everything needs to be checked