Best way to map the JPA and Hibernate ManyToMany relationship
Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.
So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!
Introduction
In this article, I’m going to show you the best way to map a ManyToMany association when using JPA and Hibernate.
As simple as JPA annotations might be, it’s not always obvious how efficient they are behind the scenes.
Domain Model
Assuming we have the following database tables:
A typical many-to-many database association includes two parent tables which are linked through a third one containing two Foreign Keys referencing the parent tables.
Implementing the ManyToMany JPA and Hibernate association using a List
The first choice for many Java developers is to use a java.util.List
for Collections that don’t entail any specific ordering.
@Entity(name = "Post") @Table(name = "post") public class Post { @Id @GeneratedValue private Long id; private String title; public Post() {} public Post(String title) { this.title = title; } @ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) private List<Tag> tags = new ArrayList<>(); //Getters and setters ommitted for brevity public void addTag(Tag tag) { tags.add(tag); tag.getPosts().add(this); } public void removeTag(Tag tag) { tags.remove(tag); tag.getPosts().remove(this); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Post)) return false; return id != null && id.equals(((Post) o).getId()); } @Override public int hashCode() { return getClass().hashCode(); } } @Entity(name = "Tag") @Table(name = "tag") public class Tag { @Id @GeneratedValue private Long id; @NaturalId private String name; @ManyToMany(mappedBy = "tags") private List<Post> posts = new ArrayList<>(); public Tag() {} public Tag(String name) { this.name = name; } //Getters and setters ommitted for brevity @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Tag tag = (Tag) o; return Objects.equals(name, tag.name); } @Override public int hashCode() { return Objects.hash(name); } }
There are several aspects to note on the aforementioned mapping that are worth explaining/
First of all, the tags
association in the Post
entity only defines the PERSIST
and MERGE
cascade types. As explained in this article, the REMOVE
entity state transition doesn’t make any sense for a @ManyToMany
JPA association since it could trigger a chain deletion that would ultimately wipe both sides of the association.
As explained in this article, the add/remove utility methods are mandatory if you use bidirectional associations so that you can make sure that both sides of the association are in sync.
The Post
entity uses the entity identifier for equality since it lacks any unique business key. As explained in this article, you can use the entity identifier for equality as long as you make sure that it stays consistent across all entity state transitions.
The Tag
entity has a unique business key which is marked with the Hibernate-specific @NaturalId
annotation. When that’s the case, the unique business key is the best candidate for equality checks.
The mappedBy
attribute of the posts
association in the Tag
entity marks that, in this bidirectional relationship, the Post
entity owns the association. This is needed since only one side can own a relationship, and changes are only propagated to the database from this particular side.
For more details about the
@NaturalId
annotation, check out this article.
Although the mapping is correct from a JPA and Hibernate perspective, from a database perspective, the previous ManyToMany relationship mapping is not efficient at all. To understand why it is so, you need to log and analyze the automated generated SQL statements.
Considering we have the following entities:
final Long postId = doInJPA(entityManager -> { Post post1 = new Post("JPA with Hibernate"); Post post2 = new Post("Native Hibernate"); Tag tag1 = new Tag("Java"); Tag tag2 = new Tag("Hibernate"); post1.addTag(tag1); post1.addTag(tag2); post2.addTag(tag1); entityManager.persist(post1); entityManager.persist(post2); return post1.id; });
When removing a Tag
entity from a Post
:
doInJPA(entityManager -> { Tag tag1 = new Tag("Java"); Post post1 = entityManager.find(Post.class, postId); post1.removeTag(tag1); });
Hibernate generates the following SQL statements:
SELECT p.id AS id1_0_0_, t.id AS id1_2_1_, p.title AS title2_0_0_, t.name AS name2_2_1_, pt.post_id AS post_id1_1_0__, pt.tag_id AS tag_id2_1_0__ FROM post p INNER JOIN post_tag pt ON p.id = pt.post_id INNER JOIN tag t ON pt.tag_id = t.id WHERE p.id = 1 DELETE FROM post_tag WHERE post_id = 1 INSERT INTO post_tag ( post_id, tag_id ) VALUES ( 1, 3 )
So, instead of deleting just one post_tag
entry, Hibernate removes all post_tag
rows associated with the given post_id
and reinserts the remaining ones back afterward. This is not efficient at all because it’s extra work for the database, especially for recreating indexes associated with the underlying Foreign Keys.
For this reason, it’s not a good idea to use the java.util.List
for @ManyToMany
JPA associations.
YouTube Video
I also published a YouTube video about the @ManyToMany
association, so enjoy watching it if you’re interested in this topic.
Implementing the ManyToMany JPA and Hibernate association using a Set
Instead of a List
, we can use a Set
.
The Post
entity tags
association will be changed as follows:
@ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) private Set<Tag> tags = new HashSet<>();
And the Tag
entity will undergo the same modification:
@ManyToMany(mappedBy = "tags") private Set<Post> posts = new HashSet<>();
If you worry about the lack of a predefined entry order, then you need to use a
SortedSet
instead ofSet
while providing either a@SortNatural
or a@SortComparator
.For instance, if the
Tag
entity implementsComparable
, you can use the@SortNatural
annotation as illustrated by the following example:@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @JoinTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) @SortNatural private SortedSet<Tag> tags = new TreeSet<>();
Now, when rerunning the previous test case, Hibernate generates the following SQL statements:
SELECT p.id AS id1_0_0_, t.id AS id1_2_1_, p.title AS title2_0_0_, t.name AS name2_2_1_, pt.post_id AS post_id1_1_0__, pt.tag_id AS tag_id2_1_0__ FROM post p INNER JOIN post_tag pt ON p.id = pt.post_id INNER JOIN tag t ON pt.tag_id = t.id WHERE p.id = 1 DELETE FROM post_tag WHERE post_id = 1 AND tag_id = 3
Much better! There is only one DELETE statement executed which removes the associated post_tag
entry.
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
Using JPA and Hibernate is very convenient since it can boost developer productivity. However, this does not mean that you have to sacrifice application performance.
By choosing the right mappings and data access pattern, you can make the difference between an application that barely crawls and one that runs at warp speed.
So, when using the @ManyToMany
annotation, always use a java.util.Set
and avoid the java.util.List
.
