Don't use Lombok with JPA until you read this article

Lombok — is a really great tool. One line of code, and all your JPA entities stop working correctly 😉 But this is only if you don't know which Lombok features can be used with JPA, and which ones are better not to.

In this article I will cover most of the pitfalls that you may encounter when using Lombok with JPA and how to avoid them using Amplicode.

Spoiler

In most cases, I will use images to demonstrate code snippets. This approach allows me to highlight important parts and explain them in more detail. If you want to check everything yourself by running the code in question, you can find it at GitHub.

The article is also available in video format at YouTube And VK Videoso you can both watch and read – whichever is more convenient for you!

@EqualsAndHashCode annotation

The first annotation that can cause problems is the annotation @EqualsAndHashCode. What can I say? The methods themselves equals() And hashCode() — a topic that can cause a lot of heated debate, and especially in the context of JPA! Just look at dozens of questions on Stackoverflow in the style of “How to properly override equals() And hashCode() for JPA entities?” and about the same number of articles trying to answer this question.

What is most interesting is that despite the apparent abundance of information, it is still almost impossible to find the correct implementation.

Lombok, among other things, generates method implementations that are not suitable for use with JPA entities. Why? Let's look at a simple test as an example.

We have an entity with several fields, in the test we create an instance of this entity, put it in HashSetafter which we save the entity and try to find it in the collection into which we actually just put it.

Despite its apparent banality, the test will fail.

The whole point is that Lombok generates implementations of methods equals() And hashCode()based on all fields declared in the entity. Let's verify this. To do this, we'll use the Delombok action from IntelliJ IDEA:

As you can see, for equals()and for hashCode() Lombok uses all fields declared in an entity:

Since we have a field idthe value of which after the creation of the entity is nulland changes to some specific value only after saving to the database, the value hashCode this same entity will differ before and after saving to the database.

Let's check that this is indeed the case by setting a breakpoint in the test and running it in debug mode. As you can see, initially id in our essence null:

However, immediately after saving id our essence changes:

As a result, the meaning also changes. hashCode. Since we put the essence in hashSet even before her id changes, then its position in hashSet is calculated relative to the old hashCode value. And now when we try to find an entity with a new idwe can't do anything, because in the method java.util.HashMap#getNode we look to see if the element we need is there, using the index calculated based on the new value hashCode.

With the current value hashCode we really didn't put any essence into ours hashSet. Therefore the method java.util.HashMap#getNode() returns nulland therefore the method java.util.HashMap#containsKey() returns false. Method java.util.HashSet#contains() also returns falseand the test fails.

Based on this, we can draw the first conclusion that the value should not be calculated hashCodestarting from the fields in entities.

In fact, if we didn't override methods at all equals() And hashCode()then the current test would pass. Let's remove the annotation from Lombok and run the test again.

The test actually passes, because the default value is hashCode will be calculated randomly and will not change in any way in the future, no matter how we change the field values.

But here's another test that the base implementation will fail. In the test, we compare two objects that represent the same database record, but are located in two different persistent contexts. To emulate this situation, we:

  1. Preserving the essence

  2. We get it with the help of EntityManager and perform the operation detach()

  3. Then we get the entity again using the method find()

  4. And finally, we check the objects for equality

Let's run the test:

In this case, the JVM considered that firstFetched And secondFetched objects are not equal. I think no one will argue that the opposite outcome would be much more logical, since both entities are linked to the same record in the database.

Well, leaving the default implementation won't work either.

In that case, let's finally see what the correct implementation of the methods will look like equals() And hashCode() and let's figure out how it works.

To overcome both problems described above, we will generate implementations of the methods equals() And hashCode() with the help of Amplicode. To do this, we will turn to the Amplicode Designer panel. (1) As a result we get the following code (2).

Amplicode knows about the problems we encountered earlier and not only, and generates the correct implementation of these methods, so let's just analyze what it generated for us and understand how these methods work.

Let's start with the method equals(). The first two lines are pretty self-explanatory. If the current object is the one passed as a parameter, then return true. If passed on nullthen we return false.

@Override
public final boolean equals(Object o) {
   if (this == o) return true;
   if (o == null) return false;
   ...
}

Further on, everything is not so obvious. We get the class of the transferred object and the current one, taking into account that both the current object and the transferred one may be Hibernate proxy.

@Override
public final boolean equals(Object o) {
   ...
   Class<?> oEffectiveClass = o instanceof HibernateProxy
           ? ((HibernateProxy) o).getHibernateLazyInitializer()
           .getPersistentClass()
           : o.getClass();
   Class<?> thisEffectiveClass = this instanceof HibernateProxy
           ? ((HibernateProxy) this).getHibernateLazyInitializer()
           .getPersistentClass()
           : this.getClass();
   if (thisEffectiveClass != oEffectiveClass) return false;
   ...
}

In fact, this is a really important aspect in the implementation of these methods. Since both the passed object and the current one may be Hibernate proxy, the class values ​​of these objects will differ, but this should not affect the comparison of entities associated with the same record in the database. That is why using a simple method instanceOf() to check for membership in the current class will not work here. But this is exactly the code that Lombok generates, and this is the code that is often recommended on StackOverflow.

Finally, if all checks are successful, we get id the current object, as well as the object received as a parameter.

@Override
public final boolean equals(Object o) {
   ...
   User user = (User) o;
   return getId() != null && Objects.equals(getId(), user.getId());
}

It is important to note that in order to obtain id we use exactly this method getId()rather than accessing the field directly. In the case of Hibernate, if you access the field directly, the proxy object will be initialized in any case. But if you access the field id through the method getId() and don't forget to make methods equals() And hashCode() final, then in this case there will be no initialization of the proxy object, since this situation is considered exceptional in Hibernate and is handled in a special way. Therefore, we will avoid both an additional query to the database and LazyInitializationException.

Implementation hashCode()in general it is now quite clear to us. We generate a numerical value based on the class taking into account the proxy.

@Override
public final int hashCode() {
   return this instanceof HibernateProxy
           ? ((HibernateProxy) this).getHibernateLazyInitializer()
           .getPersistentClass()
           .hashCode()
           : getClass().hashCode();
}

Note that for generation hashCode we don't use any fields now. Therefore, when changing any of the fields, we have the value hashCode will remain the same, and we will be able to find the entity in any hash-based collection, despite the fact that the value of one of the fields will change.

Let's check if our implementation works by running both tests.

The tests were successful.

Now you not only know why you shouldn't use annotations @EqualsAndHashCode from Lombok with its JPA entities, but also what the correct implementation of methods should look like equals() And hashCode().

Abstract @ToString

But using the annotation @ToString from Lombok, you can also seriously reduce the performance of your application or even cause StackOverflowError directly at runtime.

By default, Lombok includes absolutely all fields in the method toString()including associative ones. Let's make sure of this. To do this, we'll use the Delombok action from IntelliJ IDEA again:

Typically, reference fields at the JPA level are made lazy, and OneToMany And ManyToMany associations are default.

And we wouldn't expect to see additional queries to the database after logging in an entity, would we?

As always, let's turn to the test. It will be quite simple:

  1. Inserting multiple records into the database for three tables

  2. We only get one user By id

  3. We output it to the console using the method toString()

Immediately after calling the method toString() we get two more queries to the database.

Actually, I cheated a little and added an annotation @Transactional above the test. Otherwise the test would have fallen off LazyInitializationExceptionsince after calling the method toString() an attempt would be made to access the database without an open transaction.

Moreover, if we use the annotation @ToString for each entity that uses a two-way association, the application will crash StackOverflowError. To demonstrate this, let's also add an annotation @ToString and for the essence Post.

Since all fields are accessed in both entities, the call chain does not stop, and after a short time the application crashes, filling the entire stack.

Therefore, all associative fields (or at least *ToMany associations) should be excluded from generation for the method toString().

Amplicode knows about this and highlights the problem area for us. In addition, two possible solutions to the problem are offered at once:

  1. The first and simplest is to exclude all associative fields from generation for the method toString()using the annotation @ToString.Exclude.

  1. Alternatively, Amplicode offers to generate an implementation toString() again, without lazy associations.

It's up to you to decide which approach you like better. I'll choose the first one and run the test again.

As you can see, now no additional queries to the database occur after calling the method toString()This is exactly the result we wanted to achieve.

Abstract @Data

Annotation @Data from Lombok includes as many as 6 annotations:

As we already know, two of them are dangerous to use with JPA entities. These are annotations @ToString And @EqualsAndHashCode.

Details about the problems that may arise when we use @EqualsAndHashCode or @ToStringhas already been discussed above, but let's sum it up again.

Using the annotation @Data from Lombok, you may encounter:

  1. Incorrect comparisons of entities

  2. Unintentional loading of lazy collections

  3. StackOverflowError right in runtime

Instead of annotation @Data it is better to use safe associations @Getter, @Setter, @RequiredArgsConstructor And @ToString together with @ToString.Exclude over associative fields. And the methods equals() And hashCode() better to redefine yourself. Remind that use annotation @Data — is not the best idea and Amplicode will help you fix the situation in one click:

@Builder and @AllArgsConstructor annotations

Annotation @Builder from Lombok implements an entire design pattern for us in just one line, but unfortunately breaks the JPA specification by removing the parameterless constructor required for JPA entities.

Let's make sure of this:

As you can see, now my entity has a constructor with parameters, but without it, it doesn’t.

By the way, the annotation does the same thing @AllArgsConstructor

If we try to save an entity that uses one of these annotations, we get JpaSystemException.

So don't forget to add an annotation @NoArgsConstructor when using annotations @Builder or @AllArgsConstructor. Amplicode will help you not to forget about it thanks to inspection and add the necessary annotations or generate the necessary constructors in one click:

Conclusion: Is Lombok really that bad?

To sum it up, I would like to note that Lombok is a really useful and convenient library that allows you to reduce a huge amount of boilerplate code. But like any other library, you need to know how to use it correctly and keep in mind that everything has its advantages and disadvantages.

However, it seems that in conjunction with Amplicode you can get around most of the shortcomings of Lombok that it has in the context of using it together with JPA.

Subscribe to our Telegram And YouTubeso as not to miss new materials about AmplicodeSpring and related technologies!

And if you want to try Amplicode in action, you can install it absolutely free right now, as in IntelliJ IDEA/GigaIDEand in VS Code.

Similar Posts

Leave a Reply

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