In this post we will take a look at couple of ways how many-to-many relationships, which have additional information, can be mapped to entities using JPA@ManyToMany annotation cannot be applied in such cases as it does not allow to map additional information from the join table.

To show these approaches let's consider such small film rental database schema:


We have three main entity tables - actor, film, and customer. Where film_to_actor as well as rental can be called join tables with additional information. Actors are playing in the films as characters, and in our case an actor can play only one character in one film. This is represented by film_to_actor table. A customer can rent films, and he/she is not limited to one rent of a movie. Hence the rental table. Notice the difference between the rental and film_to_actor tables. film_to_actor has a composite key built from two foreign keys (film_id, actor_id), whereas rental has it's own id (as well as uuid similar to other main entities). Such difference is based on the business logic that we described earlier, as a pair of actor and film is unique we don't need additional id column, as for the rentals a pair of customer and film is not unique so in this case a separate id is added. This will help us show different mapping approaches.

Using @ElementCollection and Map for mapping many-to-many relationship with additional information

First thing first - we need to create a movie! After all the work on the film is finished we need to store it in the database somehow. Mapping actors and films is easy. An example of such mapping can be next:
/**
* A base entity which has an id and uuid for ease of definig hash/equals
* for an entity.
*/
@MappedSuperclass
public abstract class BaseEntity {
@Id
@SequenceGenerator(name = "sequence", sequenceName = "film_actor_sequence", allocationSize = 1)
@GeneratedValue(generator = "sequence")
private Integer id;
private String uuid = UUID.randomUUID().toString();
// id getter and hash/equals ommitted
}
// Film mapping
@Entity
@Table(name = "film")
public class Film extends BaseEntity {
private String name;
private Duration length;
}
// Actor mapping
@Entity
@Table(name = "actor")
public class Actor extends BaseEntity {
private Name name;
}
// And simple embeddable for Name:
@Embeddable
@Access(AccessType.FIELD)
public class Name {
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
}
That was easy enough, now what about a relation between films and actors? Well as, in our case, one actor can play one role in the film and actor identity defines which role it is, a Map is just asking to be used. An actor would serve as a map key and actor's role would be a value. Let's add such map to film entity as it is a logical place to store such information and map it with @ElementCollection.
/**
* Film entity updated with the {@link ElementCollection} Map to store
* film_to_actor relationship.
*/
@Entity
@Table(name = "film")
public class Film extends BaseEntity {
private String name;
private Duration length;
@ElementCollection
@CollectionTable(name = "film_to_actor",
joinColumns = @JoinColumn(name = "film_id")
)
@MapKeyJoinColumn(name = "actor_id")
private Map<Actor, CharacterInformation> characterInformation = new HashMap<>();
}
Note, as there are three values that we need to map (main_character, first_name, last_name), we need to create an Embeddable to hold them all together at the same time:
/**
* An embeddable to store character information which consists of
* first and last names represented by {@link Name} embeddable and
* a boolean value if it is a main character of the movie or not.
*/
@Embeddable
public class CharacterInformation implements Serializable {
private Name name;
@Column(name = "main_character")
private boolean mainCharacter;
}
In result we would have next class diagram:

If we would like to rather have information about in which films an actor played which character, we would place a Map in actor entity, where a film would be a map key and character information would be a map value.
/**
* Shows an example of using an element collection mapping on the actor side
* of the relationship.
*/
@Entity
@Table(name = "actor")
public class Actor extends BaseEntity {
private Name name;
@ElementCollection
@CollectionTable(name = "film_to_actor",
joinColumns = @JoinColumn(name = "actor_id")
)
@MapKeyJoinColumn(name = "film_id")
private Map<Film, CharacterInformation> characterInformation = new HashMap<>();
}
We shouldn't have both such maps at the same time, mapping the same table using element collection. We should only either use it in Film or in Actor, but never in both. Let's give it a try and persist a film:
Actor actor1 = Actor.with( Name.from( "John", "Doe" ) );
Actor actor2 = Actor.with( Name.from( "Jane", "Doe" ) );
// need to persist actors separately as operation will not be cascaded
entityManager.persist( actor1 );
entityManager.persist( actor2 );
Film film = Film.Builder.instance()
.title( "Best film ever" )
.lenth( Duration.ofHours( 1 ).plusMinutes( 20 ) )
.create();
film.addCharacter(
actor1,
CharacterInformation.Builder.instance()
.withName( Name.from( "John", "Smith" ) )
.isMainCharacter( true )
.create()
);
film.addCharacter(
actor2,
CharacterInformation.Builder.instance()
.withName( Name.from( "Jane", "Smith" ) )
.isMainCharacter( true )
.create()
);
entityManager.persist( film );
view raw SavingFilm.java hosted with ❤ by GitHub
There are two things that we should look closer at here. First note that we need to persist actors separately, as we cannot benefit from cascading. But in a real application you would, most likely,  have actors persisted already. Next note that we are not exposing the Map to the users. Even though it's a good container to store key-value pairs it's much better and cleaner to provide methods on Film class to work with actors logic instead. Like in our test example we added a Film#addCharacter method:
public class Film extends BaseEntity {
// ...
public Film addCharacter(Actor actor, CharacterInformation information) {
characterInformation.put( actor, information );
return this;
}
}
which simply delegates the operation to the map. Similarly we can add a few more methods like this depending on the application needs:
public class Film extends BaseEntity {
// ...
/**
* @return a collection of actors that play in the film
*/
public Collection<Actor> listActors() {
return characterInformation.keySet().stream()
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) );
}
/**
* @return a collection of characters in the film
*/
public Collection<CharacterInformation> listCharacters() {
return characterInformation.values().stream()
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) );
}
/**
* Removes an actor form the film.
*
* @param actor an actor to remove
*
* @return {@link CharacterInformation} wrapped in {@link Optional}. Will be empty if there was
* no such actor in the film, will contain the actor's role otherwise.
*/
public Optional<CharacterInformation> removeActor(Actor actor) {
return Optional.ofNullable( characterInformation.remove( actor ) );
}
}
This wraps the case of using element collections for mapping many-to-many relations with additional information. Let's move to another case.

Using @ManyToOne/@OneToMany and an entity to map many-to-many relationship with additional information

So we have a film and now we want to rent and watch it. If the film is good it might be rented multiple times, hence the difference in the schema. We need to be able to store a relation between same pairs of films and customers multiple times. Therefore Map will not suite our needs. Also it is worth mentioning that the renting relation itself (rental table), in some sense, represents an entity, which we might be interested in as an event of renting a film. For example we might like to report on how frequently some film is rented etc.
We already have film mapped, so we need only to map a customer and rental relationship. As we can see from our database diagram customer is basically the same as actor table, thus the mapping of basic fields for it will look the same. What is interesting here is how to map a rental relationship.
As you might already figured out we need to create a Rental entity first:
@Entity
@Table(name = "rental")
public class Rental extends BaseEntity {
@ManyToOne
private Film film;
@ManyToOne
private Customer customer;
private LocalDateTime date;
}
view raw Rental.java hosted with ❤ by GitHub
As you can see @ManyToOne mappings were used on film and customer. Now we can reference a collection of such rentals in the customer entity:
@Entity
@Table(name = "customer")
public class Customer extends BaseEntity {
private Name name;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
private Set<Rental> rentals = new HashSet<>();
}
view raw Customer.java hosted with ❤ by GitHub
This results in next class diagram:

Let's rent a film now:
public void rentFilm() {
Customer customer = Customer.with( Name.from( "Charley", "Brown" ) );
// Film with id = 9 is prepopulated in the test data
Film film = entityManager.find( Film.class, 9 );
customer.rentFilm( film );
entityManager.persist( customer );
}
Similarly, we don't want to expose a collection of rentals "as is" from the customer entity. Instead business methods are provided in the Customer class to work with rented films.
public class Customer extends BaseEntity {
//...
/**
* Will rent a {@code film} at current date and time.
*
* @param film a film to rent
*/
public Customer rentFilm(Film film) {
rentals.add(
Rental.Builder.instance()
.film( film )
.on( LocalDateTime.now() )
.byCustomer( this )
.rent()
);
return this;
}
/**
* @param start a start date of a period
* @param end an end date of the period
*
* @return a collection of distinct films rented by current customer during a time period
* between {@code start} and {@code end} dates
*/
public Collection<Film> uniqueRentedFilms(LocalDateTime start, LocalDateTime end) {
return rentals.stream()
.filter( rental -> rental.isRentedBetween( start, end ) )
.map( Rental::getFilm )
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) );
}
/**
* @return a collection of distinct films rented for the whole time
*/
public Collection<Film> uniqueRentedFilmsForAllTime() {
return rentals.stream()
.map( Rental::getFilm )
.collect( Collectors.collectingAndThen( Collectors.toSet(), Collections::unmodifiableSet ) );
}
/**
* @return rental information represented by a map where rented {@link Film} is a key
* and a list of dates when the {@code film} was rented as a value.
*/
public Map<Film, List<LocalDateTime>> rentalInformation() {
return rentals.stream()
.collect(
Collectors.collectingAndThen(
Collectors.groupingBy(
Rental::getFilm,
Collectors.mapping(
Rental::getDate,
Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList
)
)
),
Collections::unmodifiableMap
)
);
}
}
It is worth mentioning that if we modeled the schema slightly differently we would also be able to use a Map and @OneToMany to map rentals. Assuming that at a given moment of time, a customer can rent just one film, we would be able to use a Map<LocalDateTime, Film>. In this case rental table would be designed as:
-- auto-generated definition
CREATE TABLE rental
(
-- having such primary key is not mandatory. we could use a composite one
-- constructed from all three columns - date, film_id, actor_id as in our case
-- such combination will be unique.
id INTEGER DEFAULT nextval( 'film_actor_sequence' :: REGCLASS ) NOT NULL
CONSTRAINT rental_pkey
PRIMARY KEY,
-- we will not use uuid in this case of mapping as rental will not be an entity but just
-- a join table. All other columns remains as previously
-- uuid VARCHAR(255) NOT NULL,
date TIMESTAMP,
customer_id INTEGER
CONSTRAINT rental_customer_id_fkey
REFERENCES customer,
film_id INTEGER
CONSTRAINT rental_film_id_fkey
REFERENCES film
);
view raw rental.sql hosted with ❤ by GitHub

As mentioned in the comments in the SQL snippet - we could also get rid of the id column and create a composite key from all columns that remains.
And the mapping in the customer entity in such case would be:
@Entity
@Table(name = "customer")
public class Customer extends BaseEntity {
private Name name;
@OneToMany(cascade = CascadeType.ALL)
@JoinTable(name = "rental",
joinColumns = @JoinColumn(name ="customer_id"),
inverseJoinColumns = @JoinColumn(name = "film_id")
)
@MapKeyColumn(name = "date")
private Map<LocalDateTime, Film> rentalsByDateTime = new HashMap<>();
}

Conclusions

A multiple possible ways to map many-to-many relationships using just JPA was presented as well as the way how to hide the plain relationship collection operations for users behind business methods on entities. Modeling the data is a very interesting topic and there are a lot of different ways you can do such kind of mappings. Choose the one that suites your needs the best.
Personally I would suggest using maps (either as element collections, or mapped as @OneToMany) in those cases where the relationship table is more representing a connection between other entities with some additional information, rather than being an entity by itself. In this case you will have a smaller amount of entity classes and your model will look cleaner to the end user, if you hide the work with maps behind entity's business methods. Another benefit of using maps in modeling this kind of relationships comes from it's support of unique keys by design. On the other hand if the many-to-many relationship feels more like an entity itself and might be used in application outside of the entities connected by this relationship - then model it like an entity.

All the code samples can be found on GitHub.

To be continued ...