By the end of this session, you will be able to:
- Model One-to-One relationships with
@OneToOneand@JoinColumn - Model One-to-Many / Many-to-One relationships with
@OneToManyand@ManyToOne - Model Many-to-Many relationships with
@ManyToManyand@JoinTable - Distinguish between unidirectional and bidirectional relationships
- Control Lazy vs Eager fetching strategies
- Handle circular references with
@JsonIgnore - Build a multi-entity API with related entities and test it with Postman
In Java, one object can reference another — that's how relationships work. In databases, relationships are built with foreign keys. JPA bridges the gap by letting you annotate Java fields to describe those relationships.
| Type | Annotation | Real-world example |
|---|---|---|
| One-to-One | @OneToOne |
An Artist has one Profile |
| One-to-Many | @OneToMany |
An Artist has many Albums |
| Many-to-One | @ManyToOne |
Many Albums belong to one Artist |
| Many-to-Many | @ManyToMany |
Many Songs appear on many Playlists |
- Unidirectional: Only one side knows about the other.
Albumknows itsArtist, butArtistdoesn't know its albums. - Bidirectional: Both sides know about each other.
Artisthas a list ofAlbums, and eachAlbumpoints back to itsArtist.
In bidirectional relationships, one side is the owner (has @JoinColumn) and the other is the inverse (has mappedBy).
| Annotation | Meaning |
|---|---|
@JoinColumn(name = "artist_id") |
"I own this relationship — my table has the FK column" |
mappedBy = "artist" |
"The other side owns it — look at their field named artist" |
When JPA loads an entity, should it also load its related entities?
| Strategy | Behavior | Default for |
|---|---|---|
| EAGER | Load immediately with the parent | @OneToOne, @ManyToOne |
| LAZY | Load only when accessed | @OneToMany, @ManyToMany |
You can override the default:
@OneToMany(mappedBy = "artist", fetch = FetchType.EAGER) // force eager
@ManyToOne(fetch = FetchType.LAZY) // force lazyWhen both sides of a bidirectional relationship serialize to JSON, they can cause an infinite loop (Artist → Albums → Artist → Albums → ...). Fix it with @JsonIgnore on the inverse side:
@OneToMany(mappedBy = "artist")
@JsonIgnore
private List<Album> albums;We'll build an API for a music platform with three entities and all three relationship types:
- Artist ↔ ArtistProfile (One-to-One)
- Artist ↔ Album (One-to-Many / Many-to-One)
- Album ↔ Song (One-to-Many / Many-to-One)
- Song ↔ Playlist (Many-to-Many)
CREATE DATABASE music_platform;
USE music_platform;
CREATE TABLE artist_profile (
id BIGINT NOT NULL AUTO_INCREMENT,
bio TEXT,
website VARCHAR(200),
instagram_handle VARCHAR(100),
PRIMARY KEY (id)
);
CREATE TABLE artist (
id BIGINT NOT NULL AUTO_INCREMENT,
artist_name VARCHAR(100) NOT NULL,
genre VARCHAR(50),
country VARCHAR(50),
profile_id BIGINT UNIQUE,
PRIMARY KEY (id),
FOREIGN KEY (profile_id) REFERENCES artist_profile(id)
);
CREATE TABLE album (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(150) NOT NULL,
release_year INT,
artist_id BIGINT,
PRIMARY KEY (id),
FOREIGN KEY (artist_id) REFERENCES artist(id)
);
CREATE TABLE song (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(150) NOT NULL,
duration_seconds INT,
album_id BIGINT,
PRIMARY KEY (id),
FOREIGN KEY (album_id) REFERENCES album(id)
);
CREATE TABLE playlist (
id BIGINT NOT NULL AUTO_INCREMENT,
playlist_name VARCHAR(100) NOT NULL,
created_by VARCHAR(100),
PRIMARY KEY (id)
);
CREATE TABLE playlist_song (
playlist_id BIGINT NOT NULL,
song_id BIGINT NOT NULL,
PRIMARY KEY (playlist_id, song_id),
FOREIGN KEY (playlist_id) REFERENCES playlist(id),
FOREIGN KEY (song_id) REFERENCES song(id)
);Notice:
artist.profile_idis a FK withUNIQUE— that's the One-to-One.album.artist_idis a FK — that's the Many-to-One.playlist_songis a join table — that's the Many-to-Many.
import jakarta.persistence.*;
@Entity
@Table(name = "artist_profile")
public class ArtistProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String website;
@Column(name = "instagram_handle")
private String instagramHandle;
public ArtistProfile() {}
public ArtistProfile(String bio, String website, String instagramHandle) {
this.bio = bio;
this.website = website;
this.instagramHandle = instagramHandle;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public String getWebsite() { return website; }
public void setWebsite(String website) { this.website = website; }
public String getInstagramHandle() { return instagramHandle; }
public void setInstagramHandle(String instagramHandle) { this.instagramHandle = instagramHandle; }
}import jakarta.persistence.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;
@Entity
@Table(name = "artist")
public class Artist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "artist_name")
private String artistName;
private String genre;
private String country;
// One-to-One: Artist owns the relationship (has the FK column)
@OneToOne
@JoinColumn(name = "profile_id")
private ArtistProfile profile;
// One-to-Many: Artist has many Albums (inverse side)
@OneToMany(mappedBy = "artist")
@JsonIgnore // prevents circular reference in JSON
private List<Album> albums;
public Artist() {}
public Artist(String artistName, String genre, String country) {
this.artistName = artistName;
this.genre = genre;
this.country = country;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getArtistName() { return artistName; }
public void setArtistName(String artistName) { this.artistName = artistName; }
public String getGenre() { return genre; }
public void setGenre(String genre) { this.genre = genre; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
public ArtistProfile getProfile() { return profile; }
public void setProfile(ArtistProfile profile) { this.profile = profile; }
public List<Album> getAlbums() { return albums; }
public void setAlbums(List<Album> albums) { this.albums = albums; }
}Key points:
@OneToOne+@JoinColumn(name = "profile_id")— Artist owns the one-to-one relationship.@OneToMany(mappedBy = "artist")— Artist is the inverse side of the one-to-many with Album.@JsonIgnoreon albums prevents infinite JSON recursion.
import jakarta.persistence.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;
@Entity
@Table(name = "album")
public class Album {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(name = "release_year")
private Integer releaseYear;
// Many-to-One: Many Albums belong to one Artist (owner side)
@ManyToOne
@JoinColumn(name = "artist_id")
private Artist artist;
// One-to-Many: Album has many Songs
@OneToMany(mappedBy = "album")
@JsonIgnore
private List<Song> songs;
public Album() {}
public Album(String title, Integer releaseYear) {
this.title = title;
this.releaseYear = releaseYear;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Integer getReleaseYear() { return releaseYear; }
public void setReleaseYear(Integer releaseYear) { this.releaseYear = releaseYear; }
public Artist getArtist() { return artist; }
public void setArtist(Artist artist) { this.artist = artist; }
public List<Song> getSongs() { return songs; }
public void setSongs(List<Song> songs) { this.songs = songs; }
}@ManyToOne+@JoinColumn(name = "artist_id")— Album owns the relationship to Artist.@OneToMany(mappedBy = "album")— Album is inverse side for Songs.
import jakarta.persistence.*;
import java.util.Set;
@Entity
@Table(name = "song")
public class Song {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(name = "duration_seconds")
private Integer durationSeconds;
// Many-to-One: Many Songs belong to one Album
@ManyToOne
@JoinColumn(name = "album_id")
private Album album;
// Many-to-Many: A Song can be on many Playlists (owner side)
@ManyToMany(mappedBy = "songs")
@com.fasterxml.jackson.annotation.JsonIgnore
private Set<Playlist> playlists;
public Song() {}
public Song(String title, Integer durationSeconds) {
this.title = title;
this.durationSeconds = durationSeconds;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Integer getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(Integer durationSeconds) { this.durationSeconds = durationSeconds; }
public Album getAlbum() { return album; }
public void setAlbum(Album album) { this.album = album; }
public Set<Playlist> getPlaylists() { return playlists; }
public void setPlaylists(Set<Playlist> playlists) { this.playlists = playlists; }
}import jakarta.persistence.*;
import java.util.Set;
@Entity
@Table(name = "playlist")
public class Playlist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "playlist_name")
private String playlistName;
@Column(name = "created_by")
private String createdBy;
// Many-to-Many: Playlist owns the relationship (has the join table)
@ManyToMany
@JoinTable(
name = "playlist_song",
joinColumns = @JoinColumn(name = "playlist_id"),
inverseJoinColumns = @JoinColumn(name = "song_id")
)
private Set<Song> songs;
public Playlist() {}
public Playlist(String playlistName, String createdBy) {
this.playlistName = playlistName;
this.createdBy = createdBy;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getPlaylistName() { return playlistName; }
public void setPlaylistName(String playlistName) { this.playlistName = playlistName; }
public String getCreatedBy() { return createdBy; }
public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }
public Set<Song> getSongs() { return songs; }
public void setSongs(Set<Song> songs) { this.songs = songs; }
}@ManyToMany+@JoinTable— Playlist owns the many-to-many. It defines the join tableplaylist_song.- On the Song side,
mappedBy = "songs"makes Song the inverse side.
@Repository
public interface ArtistProfileRepository extends JpaRepository<ArtistProfile, Long> {}
@Repository
public interface ArtistRepository extends JpaRepository<Artist, Long> {}
@Repository
public interface AlbumRepository extends JpaRepository<Album, Long> {
List<Album> findByArtistId(Long artistId);
}
@Repository
public interface SongRepository extends JpaRepository<Song, Long> {
List<Song> findByAlbumId(Long albumId);
}
@Repository
public interface PlaylistRepository extends JpaRepository<Playlist, Long> {}@Service
public class ArtistService {
@Autowired
private ArtistRepository artistRepository;
@Autowired
private ArtistProfileRepository profileRepository;
public List<Artist> getAllArtists() { return artistRepository.findAll(); }
public Optional<Artist> getArtistById(Long id) { return artistRepository.findById(id); }
public Artist createArtist(Artist artist) { return artistRepository.save(artist); }
// Assign an existing profile to an artist
public Artist assignProfile(Long artistId, Long profileId) {
Artist artist = artistRepository.findById(artistId)
.orElseThrow(() -> new RuntimeException("Artist not found"));
ArtistProfile profile = profileRepository.findById(profileId)
.orElseThrow(() -> new RuntimeException("Profile not found"));
artist.setProfile(profile);
return artistRepository.save(artist);
}
public void deleteArtist(Long id) { artistRepository.deleteById(id); }
}@RestController
@RequestMapping("/artists")
public class ArtistController {
@Autowired
private ArtistService artistService;
@GetMapping
public List<Artist> getAll() { return artistService.getAllArtists(); }
@GetMapping("/{id}")
public Optional<Artist> getById(@PathVariable Long id) { return artistService.getArtistById(id); }
@PostMapping
public Artist create(@RequestBody Artist artist) { return artistService.createArtist(artist); }
// PUT /artists/{artistId}/profile/{profileId}
@PutMapping("/{artistId}/profile/{profileId}")
public Artist assignProfile(@PathVariable Long artistId, @PathVariable Long profileId) {
return artistService.assignProfile(artistId, profileId);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) { artistService.deleteArtist(id); }
}Build similar services and controllers for Album, Song, and Playlist. For the playlist, add an endpoint to add a song:
// In PlaylistService:
public Playlist addSongToPlaylist(Long playlistId, Long songId) {
Playlist playlist = playlistRepository.findById(playlistId)
.orElseThrow(() -> new RuntimeException("Playlist not found"));
Song song = songRepository.findById(songId)
.orElseThrow(() -> new RuntimeException("Song not found"));
playlist.getSongs().add(song);
return playlistRepository.save(playlist);
}Test in this order (create referenced entities first):
| Order | Method | URL | Body / Notes |
|---|---|---|---|
| 1 | POST | /profiles |
{"bio":"Indie rock band from UK","website":"https://arctic.com","instagramHandle":"@arcticmonkeys"} |
| 2 | POST | /artists |
{"artistName":"Arctic Monkeys","genre":"Rock","country":"UK"} |
| 3 | PUT | /artists/1/profile/1 |
Assigns profile 1 to artist 1 |
| 4 | GET | /artists/1 |
Should return artist with nested profile |
| 5 | POST | /albums |
{"title":"AM","releaseYear":2013,"artist":{"id":1}} |
| 6 | POST | /songs |
{"title":"Do I Wanna Know?","durationSeconds":272,"album":{"id":1}} |
| 7 | POST | /playlists |
{"playlistName":"Road Trip","createdBy":"Alex"} |
| 8 | PUT | /playlists/1/songs/1 |
Adds song 1 to playlist 1 |
| 9 | GET | /playlists/1 |
Should return playlist with its songs |
Check the console SQL — notice how JPA handles the INSERT INTO playlist_song join table automatically.
Now it's your turn! Build a multi-entity API for a tech conference using all three relationship types.
A conference platform needs to manage speakers, talks, rooms, and attendees.
-
Speaker ↔ SpeakerBio (One-to-One)
- Each speaker has exactly one bio (photo URL, company, LinkedIn)
- Speaker owns the relationship
-
Room ↔ Talk (One-to-Many / Many-to-One)
- A room hosts many talks, but each talk is in one room
- Talk owns the relationship (
room_idFK)
-
Speaker ↔ Talk (One-to-Many / Many-to-One)
- A speaker gives many talks, but each talk has one speaker
- Talk owns the relationship (
speaker_idFK)
-
Talk ↔ Attendee (Many-to-Many)
- A talk can have many attendees, an attendee can attend many talks
- Use a join table
talk_attendee
-
Create the database
conference_systemwith all tables and the join table. -
Create all entity classes with proper JPA annotations:
SpeakerBio:id,photoUrl,company,linkedInUrlSpeaker:id,speakerName,email,expertise+ relationship to SpeakerBio and TalksRoom:id,roomName,floor,capacity+ relationship to TalksTalk:id,talkTitle,startTime(TIME),durationMinutes+ relationships to Speaker, RoomAttendee:id,attendeeName,email,company+ relationship to Talks- Use
@JsonIgnoreto prevent circular references
-
Build Repository, Service, Controller for each entity.
-
Key endpoints:
- CRUD for all entities
PUT /speakers/{id}/bio/{bioId}— assign bio to speakerPUT /playlists/{talkId}/attendees/{attendeeId}— register attendee for talkGET /rooms/{id}/talks— get all talks in a room
-
Test with Postman — create entities in the right order and verify relationships.
Build from memory first! Only check the demo if you're stuck.
- One-to-One:
@OneToOne+@JoinColumnon the owner side,mappedByon the inverse. - One-to-Many / Many-to-One:
@ManyToOne+@JoinColumnon the "many" side (owner),@OneToMany(mappedBy)on the "one" side. - Many-to-Many:
@ManyToMany+@JoinTableon the owner,@ManyToMany(mappedBy)on the inverse. - The owner side has
@JoinColumnor@JoinTable— it controls the FK. - The inverse side has
mappedBy— it just reads from the other side. - Use
@JsonIgnoreon inverse collections to prevent infinite JSON loops. - EAGER = load immediately (default for
@OneToOne,@ManyToOne). - LAZY = load on access (default for
@OneToMany,@ManyToMany). - Always create referenced entities before the entities that reference them.
- Use
Setfor@ManyToManyto avoid duplicates; useListfor@OneToMany.