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.

Many-Many Mapping


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

  1. 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())
             + "}";
       }
     }
    
  2. 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.

DB Schema Picture

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.


References

  1. Many-Many relationship
  2. Practical Example of many-to-many SO answer
  3. An SO answer on using @JoinTable on both sides
  4. 1-1 vs 1-Many vs Many-Many SO answer