When we work with entities in JPA, they’re are often related to other entities expressed using relationships like one-to-one, one-to-many, and many-to-many associations. If you need a primer Spring JPA, I suggest reading my prior article, Learn Spring JPA By Example With Oracle Database Free.
With Spring JPA, developers can model relationships using annotations like @OneToOne, @OneToMany, @ManyToOne, and @ManyToMany to rapidly define entity mappings, controlling how your data is persisted and retrieved.
In this article, you’ll learn how to use these JPA annotations and others, effectively managing entity relationships to design maintainable, performant applications.
// A Director may have many movies, and exactly one biography
@OneToMany(mappedBy = "director")
private Set<Movie> movies;
@OneToOne(
mappedBy = "director",
cascade = CascadeType.ALL,
orphanRemoval = true
)
// The primary key of the Director entity is used as the foreign key of the DirectorBio entity.
@PrimaryKeyJoinColumn
private DirectorBio directorBio;Want to skip the article and jump to the code? Check out my spring-jpa module on GitHub.
Spring JPA and Oracle Database Dependencies
We’ll use the Spring Boot Starter for Spring Data JPA, and the Oracle Spring Boot Starter for UCP artifacts to configure the project. You can find the full dependencies used on GitHub.
Our Movie Schema for modeling relationships
In this example, we’ll use an example movie schema that models various relationships between directors, actors, and movies.
The movie schema and its relationships can be visually represented like so:

The DDL for the schema looks like this – you can run it on your database if you’d like to follow along.
create table director (
director_id number(10) generated always as identity primary key,
first_name varchar2(50) not null,
last_name varchar2(50) not null
);
-- A director has exactly one biography, and a biography is mapped to one director.
create table director_bio (
director_id number(10) primary key,
biography clob,
constraint fk_director_bio foreign key (director_id) references director(director_id) on delete cascade
);
-- A director has one or more movies. A movie has at most one director.
create table movie (
movie_id number(10) generated always as identity primary key,
title varchar2(100) not null,
release_year number(4),
genre varchar2(50),
director_id number(10),
constraint fk_movie_director foreign key (director_id) references director(director_id)
);
create table actor (
actor_id number(10) generated always as identity primary key,
first_name varchar2(50) not null,
last_name varchar2(50) not null
);
-- A movie has zero or more actors, and an actor can be in many movies.
create table movie_actor (
movie_id number(10),
actor_id number(10),
primary key (movie_id, actor_id),
constraint fk_movie_actor_movie foreign key (movie_id) references movie(movie_id),
constraint fk_movie_actor_actor foreign key (actor_id) references actor(actor_id)
);One-To-One Relationships
In the movie shema, a director has a biography, and a biography has a director, representing a one-to-one relationship. To model this with JPA, we’ll create the following entities for a Director and a DirectorBio (You may ignore the @OneToMany annotation for now, we’ll come back to it in the next section):
// Director.java
import java.util.Objects;
import java.util.Set;
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.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrimaryKeyJoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "director")
@Getter
@Setter
public class Director {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "director_id")
private Long directorId;
@Column(name = "first_name", nullable = false, length = 50)
private String firstName;
@Column(name = "last_name", nullable = false, length = 50)
private String lastName;
// We'll cover the Director-Movie relationship in the next section
@OneToMany(mappedBy = "director") // Reference related entity's associated field
private Set<Movie> movies;
@OneToOne(
mappedBy = "director", // Reference related entity's associated field
cascade = CascadeType.ALL, // Cascade persistence to the mapped entity
orphanRemoval = true // Remove director bio from director if deleted
)
// The primary key of the Director entity is used as the foreign key of the DirectorBio entity.
@PrimaryKeyJoinColumn
private DirectorBio directorBio;
public void setDirectorBio(DirectorBio directorBio) {
this.directorBio = directorBio;
if (directorBio != null) {
directorBio.setDirector(this);
}
}
@Override
public final boolean equals(Object o) {
if (!(o instanceof Director director)) return false;
return Objects.equals(getDirectorId(), director.getDirectorId());
}
@Override
public int hashCode() {
return Objects.hashCode(getDirectorId());
}
}
// DirectorBio.java
import jakarta.persistence.*;
import java.util.Objects;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "director_bio")
@Getter
@Setter
public class DirectorBio {
@Id
@Column(name = "director_id")
private Long directorId;
@OneToOne(fetch = FetchType.LAZY)
// The primary key will be copied from the director entity
@MapsId
@JoinColumn(name = "director_id")
private Director director;
@Column(name = "biography", columnDefinition = "CLOB")
private String biography;
@Override
public final boolean equals(Object o) {
if (!(o instanceof DirectorBio directorBio)) return false;
return Objects.equals(getDirectorId(), directorBio.getDirectorId());
}
@Override
public int hashCode() {
return Objects.hashCode(getDirectorId());
}
}There’s a lot to dive into here, so let’s tackle it piece-by-piece:
- The @OneToOne annotation in the Director entity creates an association with a DirectorBio entity, using the mappedBy option to reference the associated field in the related entity. The CascadeType.ALL option enforces cascading persistence for the mapped entity, and the orphanRemoval = true option marks the DirectorBio to be removed from the Director if it is deleted. The @OneToOne annotation is also present in DirectorBio on the Director field, as the relationship is bi-directional.
- We use the @PrimaryKeyJoinColumn annotation to specify that the primary key column is used as a foreign key to join with another entity. This annotation is particularly useful in one-to-one relationships where one entity’s primary key serves as both the primary key and foreign key in the related entity. See Differences between @JoinColumn and @PrimaryKeyJoinColumn for an additional explanation.
- The setDirectorBio method on the Director entity provides a helper to ensure the bidirectional relationship is maintained during inserts and updates by configuring the DirectorBio in the Director, and The Director in the DirectorBio.
- In DirectorBio, the @MapsId annotation is used to specify that the Director’s primary key is also the DirectorBio’s primary key — in the movie schema, the primary keys are shared. Sharing of primary keys is not required, but may be utilized in one-to-one relationships to save database storage space.
- Also in DirectorBio, the @JoinColumn annotation refers to the column that holds the foreign key, which is used by JPA for joining the two tables.
- Lastly, the implementations of equals and hashcode only use the primary key (and not the respective mapped Director/DirectorBio entities), preventing circular de-referencing and stack overflow errors. You can imagine that if Director uses DirectorBio in its equals method, and DirectorBio also uses Director in its equals method a circular reference is created. This should be avoided!
One-to-Many, Many-to-One Relationships
A Director may direct many movies, but a Movie has one director — This defines a one-to-many relationship from Director to Movie, and a many-to-one relationship from Movie to Director.
Let’s examine the Movie entity to get an idea of how this works, including the one-to-many relationship defined earlier in the Director entity:
import java.util.Objects;
import java.util.Set;
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.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "movie")
@Getter
@Setter
public class Movie {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "movie_id")
private Long movieId;
@Column(name = "title", nullable = false, length = 100)
private String title;
@Column(name = "release_year")
private Integer releaseYear;
@Column(name = "genre", length = 50)
private String genre;
@ManyToOne
@JoinColumn(name = "director_id")
private Director director;
// We'll cover the Actor-Movie relationship in the next section
@ManyToMany
@JoinTable(
name = "movie_actor",
joinColumns = @JoinColumn(name = "movie_id"),
inverseJoinColumns = @JoinColumn(name = "actor_id")
)
private Set<Actor> actors;
@Override
public final boolean equals(Object o) {
if (!(o instanceof Movie movie)) return false;
return Objects.equals(movieId, movie.movieId);
}
@Override
public int hashCode() {
return Objects.hashCode(movieId);
}
}Let’s break this down:
- In the Director entity, the @OneToMany annotation on a Set<Movie> uses the mappedBy option to reference the Movie’s director.
- In the Movie entity, we use the @ManyToOne annotation on the Director field to reference the bidirectional relationship.
- The @JoinColumn annotation on the Movie entity’s Director field references the director_id foreign key.
- As previously, we implement hash code and equals using only the primary keys to avoid circular dependencies and a stack overflow error.
The result is that a JPA repository queries on a Director may retrieve all the Movies credited to that Director, and repository queries on a Movie may fetch that Movie’s Director.
Many-To-Many
A Movie may have many actors, and an Actor may have many movies, representing a bidirectional many-to-many relationship. In the database, this relationship uses a join table, movie_actor. From the context of JPA, the join table is abstracted through the @ManyToMany annotation applied to the Movie and Actor entities.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "actor")
@Getter
@Setter
public class Actor {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "actor_id")
private Long actorId;
@Column(name = "first_name", nullable = false, length = 50)
private String firstName;
@Column(name = "last_name", nullable = false, length = 50)
private String lastName;
@ManyToMany(mappedBy = "actors")
private Set<Movie> movies;
/**
* Adds an Actor to a movie, maintaining bidirectional integrity.
* @param movie to add the Actor into.
*/
public void addMovie(Movie movie) {
if (movies == null) {
movies = new HashSet<>();
}
movies.add(movie);
movie.getActors().add(this);
}
@Override
public final boolean equals(Object o) {
if (!(o instanceof Actor actor)) return false;
return Objects.equals(getActorId(), actor.getActorId());
}
@Override
public int hashCode() {
return Objects.hashCode(getActorId());
}
}Let’s look at the many-to-many details of the Actor and Movie entities:
- On the Movie entity, we specify the @JoinTable movie_actor and the @JoinColumns movie_id and actor_id. While the use of @JoinTable is not mandatory and you could get by with mappedBy, it specifies Movie as the “owning” entity and allows us to have one join table, instead of two. This is suitable for our schema, as the actor-movie relationship is bidirectional.
- Both the Movie and Actor entities use the @ManyToMany annotation on their respective Set<Actor> and Set<Movie> fields. This allows JPA repositories to query all actors for a movie, and all movies for an actor.
- Proper use of the @ManyToMany annotation allows JPA to manage records in the join table when we create new actors and movies. The helper method addMovie in the Actor entity demonstrates this, ensuring the bi-directionality of the relationship is maintained.
Repository Definitions
With our entities defined, we’ll create repositories for the Director, DirectorBio, Actor, and Movie entities. Because the MovieActor join table is handled implicitly by JPA, we’ll create neither an entity nor a repository for it.
You can find the repository definitions on GitHub – I don’t copy them here, as they are simple simple interfaces that only reference the corresponding entity classes.
Testing Our Movie Schema Relationships
The entities and relationships described in this article are tested in the JPARelationshipsTest class of the spring-jpa module. The test leverages Testcontainers to create a throwaway Oracle Database Free instance — If your development machine is capable of running Docker containers, you can run the test using Maven like so:
mvn test -Dtest=JPARelationshipsTest
The test provides examples of working with one-to-one, one-to-many, and many-to-many relationships using Spring JPA repositories. Read each test case to see how JPA repositories can be used to perform CRUD operations on the related entities:
import java.time.Duration;
import java.util.Optional;
import com.example.relationships.model.Actor;
import com.example.relationships.model.Director;
import com.example.relationships.model.DirectorBio;
import com.example.relationships.model.Movie;
import com.example.relationships.repository.ActorRepository;
import com.example.relationships.repository.DirectorBioRepository;
import com.example.relationships.repository.DirectorRepository;
import com.example.relationships.repository.MovieRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.oracle.OracleContainer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@Testcontainers
public class JPARelationshipsTest {
@Container
@ServiceConnection
static OracleContainer oracleContainer = new OracleContainer("gvenzl/oracle-free:23.5-slim-faststart")
.withStartupTimeout(Duration.ofMinutes(2))
.withUsername("testuser")
.withPassword("testpwd")
.withInitScript("movie.sql");
// Autowire JPA repositories
@Autowired
DirectorRepository directorRepository;
@Autowired
DirectorBioRepository directorBioRepository;
@Autowired
MovieRepository movieRepository;
@Autowired
ActorRepository actorRepository;
@Test
void oneToOneExample() {
Optional<Director> director = directorRepository.findByFirstNameAndLastName("Christopher", "Nolan");
assertTrue(director.isPresent());
// Verify the biography was fetched.
assertThat(director.get().getDirectorBio().getBiography()).isNotEmpty();
// Create a new Director
Director newDirector = new Director();
newDirector.setFirstName("Steven");
newDirector.setLastName("Spielberg");
// Create a new DirectorBio
DirectorBio directorBio = new DirectorBio();
directorBio.setBiography("Steven Spielberg is an iconic American filmmaker known for blockbuster films like Jaws, E.T., and Jurassic Park.");
// Set the relationships
newDirector.setDirectorBio(directorBio);
Director savedDirector = directorRepository.save(newDirector);
// Verify the bio was added
Optional<DirectorBio> fetchedBio = directorBioRepository.findById(savedDirector.getDirectorId());
assertTrue(fetchedBio.isPresent());
assertThat(fetchedBio.get().getBiography()).isEqualTo(directorBio.getBiography());
// Delete the director, and the CASCADE effect deletes the bio
directorRepository.delete(savedDirector);
assertFalse(directorBioRepository.findById(savedDirector.getDirectorId()).isPresent());
}
@Test
// The use of the Transactional annotation here will keep the database session open for relational queries, e.g., Movie-Actor.
// This is useful for lazy initialization of JPA fields, so related data is fetched when-needed.
@Transactional
void oneToManyExample() {
Optional<Movie> pulpFiction = movieRepository.findByTitle("Pulp Fiction");
assertThat(pulpFiction.isPresent()).isTrue();
// Verify the Movie-Actor many-to-many relationship
Optional<Actor> samuelJackson = actorRepository.findByFirstNameAndLastName("Samuel", "Jackson");
assertThat(samuelJackson.isPresent()).isTrue();
// Samuel Jackson is credited to Pulp Fiction (Actor -> Movie view)
assertThat(samuelJackson.get().getMovies()).contains(pulpFiction.get());
// Pulp Fiction credits Samuel Jackson (Movie -> Actor view)
assertThat(pulpFiction.get().getActors()).contains(samuelJackson.get());
// Verify the Director-Movie many-to-one relationship
Director pulpFictionDirector = pulpFiction.get().getDirector();
// Pulp Fiction credits Quentin Tarantino as the director.
assertThat(pulpFictionDirector).isNotNull();
assertThat(pulpFictionDirector.getFirstName()).isEqualTo("Quentin");
// Quentin Tarantino lists Pulp Fiction as a directed movie.
assertThat(pulpFictionDirector.getMovies()).contains(pulpFiction.get());
}
@Test
// For lazy loading sessions, as in oneToManyExample
@Transactional
void manyToManyExample() {
Optional<Movie> pulpFiction = movieRepository.findByTitle("Pulp Fiction");
assertThat(pulpFiction.isPresent()).isTrue();
// Create a new actor.
Actor newActor = new Actor();
newActor.setFirstName("Uma");
newActor.setLastName("Thurman");
newActor.addMovie(pulpFiction.get());
// The Movie-Actor relationship is implicitly created by Spring JPA
actorRepository.save(newActor);
// Verify Pulp Fiction is in Uma Thurman's credits.
Optional<Actor> umaThurman = actorRepository.findByFirstNameAndLastName("Uma", "Thurman");
assertTrue(umaThurman.isPresent());
assertThat(umaThurman.get().getMovies()).contains(pulpFiction.get());
// What about update? The Movie-Actor relationship is implicitly inserted on update.
Optional<Movie> killBillVol1 = movieRepository.findByTitle("Kill Bill: Vol. 1");
assertThat(killBillVol1.isPresent()).isTrue();
umaThurman.get().addMovie(killBillVol1.get());
Actor updated = actorRepository.save(umaThurman.get());
assertThat(updated.getMovies()).contains(killBillVol1.get());
}
}JPA is a powerful abstraction layer that removes much of the manual work associated with developing an application’s persistence layer. However, care must be taken to properly use JPA’s toolset to ensure performance and maintainability. I highly suggest enabling the spring.jpa.show-sql=true property during development to see exactly what queries are run by the persistence layer, allowing you to optimize your JPA schemas effectively.
I hope this article helps get started on your journey to learn and develop with JPA entity relationships — It’s a complex topic that takes time and effort to master, but can impressively boost developer productivity and efficiency.

Leave a Reply