Many-Many JPA Mapping with Spring Boot
The goal of this post is to establish Many-Many relationship between 2 JPA entities.
Here are some of the real-world examples of Many-Many relationship
student - course
product - store
person - hobby
consultant - project
actor - film
Consider actor - film
example with the following schema.
actor
actor_id_pk | name |
---|---|
1 | Leonardo DiCaprio |
2 | Matt Damon |
film
film_id_pk | name |
---|---|
1 | The Departed |
2 | The Revenant |
3 | Jason Bourne |
Let’s try to map the actor to film with a join table
actor_film
actor_id_fk | film_id_fk |
---|---|
1 | 1 |
1 | 2 |
2 | 1 |
2 | 3 |
Needless to say that they form a Many-Many
association.
Database Schema:
Relationship between actor
, film
, and actor_film
tables.
JPA Entities:
In Many-Many
relationship no entity will have a foreign key reference, rather the foreign keys are defined in a join table. So technically, there is no owning side. In this example, I have used @JoinTable
on both sides.
Here are the JPA entities
-
Actor
package mtm; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; import jakarta.persistence.Table; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; /** * @author sairaghavak */ @Entity(name = "Actor") @Table(name = "actor") @NoArgsConstructor @EqualsAndHashCode(exclude = {"films"}) public class Actor { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "actor_id_pk") private Long id; @Column(name = "name") private String name; @ManyToMany(cascade = CascadeType.ALL) // Not recommended to add fetch = FetchType.EAGER here @JoinTable( name = "film_actor", joinColumns = { @JoinColumn( name = "actor_id_fk", referencedColumnName = "actor_id_pk" ) }, inverseJoinColumns = { @JoinColumn( name = "film_id_fk", referencedColumnName = "film_id_pk" ) } ) private Set<Film> films = new HashSet<>(); public Actor(String name) { this.name = name; } public boolean addFilm(Film film) { return films.add(film); } public Set<Film> getFilms() { return films; } public String getName() { return name; } public String toString() { return "{Actor {" + name + "} worked in films " + getFilms().stream().map(Film::getName).collect(Collectors.toList()) + "}"; } }
-
Film
package mtm; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; import jakarta.persistence.Table; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; /** * @author sairaghavak */ @Entity(name = "Film") @Table(name = "film") @NoArgsConstructor @EqualsAndHashCode(exclude = {"actors"}) public class Film { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "film_id_pk") private Long id; @Column(name = "name") private String name; @ManyToMany(cascade = CascadeType.ALL) // Not recommended to add fetch = FetchType.EAGER here @JoinTable( name = "film_actor", joinColumns = { @JoinColumn( name = "film_id_fk", referencedColumnName = "film_id_pk" ) }, inverseJoinColumns = { @JoinColumn( name = "actor_id_fk", referencedColumnName = "actor_id_pk" ) } ) private Set<Actor> actors = new HashSet<>(); public Film(String name, Actor... actors) { this.name = name; this.actors = Stream.of(actors).collect(Collectors.toSet()); } public boolean addActor(Actor actor) { return actors.add(actor); } public String getName() { return name; } public Set<Actor> getActors() { return actors; } public String toString() { return "{Film {" + name + "} has actors " + getActors().stream().map(Actor::getName).collect(Collectors.toList()) + "}"; } }
With these entities declared and configured we can save both the Actor
and Film
through ActorRepository
or via FilmRepository
which would insert data into actor
, film
, film_actor
tables because the @ManyToMany(cascade = CascadeType.ALL)
is declared in both the entities.
And, notice that there is no fetch = FetchType.EAGER
attribute in @ManyToMany
because it’s not performant to have it on the collection. Instead, @EntityGraph
is applied on fetch methods in Repository interfaces.
For Bidirectional association, when we fetch a specific entity it has to pull all its associations from DB and reverse should also work i.e., fetching the other entity should also pull all it associated entities. In this example, given an actor it will fetch all the associated films, similarly given a film, it will fetch the actors associated with it. Here is the final state of the db after running the application.
In a nutshell, the key to implement Many-Many JPA mapping is using @JoinTable
on both sides of the relationship, and ensure that the values of joinColumns
and inverseJoinColumns
in @JoinTable
of each entity are different.
For detailed working sample refer my GitHub repo mtm-jpa-mapping-with-spring-boot.