Skip to content

Latest commit

 

History

History
611 lines (449 loc) · 19.7 KB

File metadata and controls

611 lines (449 loc) · 19.7 KB

Learning Objectives

By the end of this session, you will be able to:

  • Model One-to-One relationships with @OneToOne and @JoinColumn
  • Model One-to-Many / Many-to-One relationships with @OneToMany and @ManyToOne
  • Model Many-to-Many relationships with @ManyToMany and @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

1. Theory — Entity Relationships in JPA

The big picture

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.

Relationship types at a glance

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 vs Bidirectional

  • Unidirectional: Only one side knows about the other. Album knows its Artist, but Artist doesn't know its albums.
  • Bidirectional: Both sides know about each other. Artist has a list of Albums, and each Album points back to its Artist.

In bidirectional relationships, one side is the owner (has @JoinColumn) and the other is the inverse (has mappedBy).

@JoinColumn vs 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"

Fetching strategies

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 lazy

Circular reference problem

When 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;

2. Live-Coding Demo — Music Platform API

We'll build an API for a music platform with three entities and all three relationship types:

  • ArtistArtistProfile (One-to-One)
  • ArtistAlbum (One-to-Many / Many-to-One)
  • AlbumSong (One-to-Many / Many-to-One)
  • SongPlaylist (Many-to-Many)

Step 1: Database setup

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_id is a FK with UNIQUE — that's the One-to-One.
  • album.artist_id is a FK — that's the Many-to-One.
  • playlist_song is a join table — that's the Many-to-Many.

Step 2: Entity — ArtistProfile (One-to-One target)

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; }
}

Step 3: Entity — Artist (One-to-One owner + One-to-Many parent)

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.
  • @JsonIgnore on albums prevents infinite JSON recursion.

Step 4: Entity — Album (Many-to-One child)

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.

Step 5: Entity — Song (Many-to-Many owner)

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; }
}

Step 6: Entity — Playlist (Many-to-Many owner)

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 table playlist_song.
  • On the Song side, mappedBy = "songs" makes Song the inverse side.

Step 7: Repositories

@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> {}

Step 8: Service (Artist example with relationship handling)

@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); }
}

Step 9: Controller (Artist example)

@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);
}

Step 10: Test with Postman

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.


3. Guided Practice — Conference System API

Now it's your turn! Build a multi-entity API for a tech conference using all three relationship types.

Scenario

A conference platform needs to manage speakers, talks, rooms, and attendees.

Entities and Relationships

  1. SpeakerSpeakerBio (One-to-One)

    • Each speaker has exactly one bio (photo URL, company, LinkedIn)
    • Speaker owns the relationship
  2. RoomTalk (One-to-Many / Many-to-One)

    • A room hosts many talks, but each talk is in one room
    • Talk owns the relationship (room_id FK)
  3. SpeakerTalk (One-to-Many / Many-to-One)

    • A speaker gives many talks, but each talk has one speaker
    • Talk owns the relationship (speaker_id FK)
  4. TalkAttendee (Many-to-Many)

    • A talk can have many attendees, an attendee can attend many talks
    • Use a join table talk_attendee

Requirements

  1. Create the database conference_system with all tables and the join table.

  2. Create all entity classes with proper JPA annotations:

    • SpeakerBio: id, photoUrl, company, linkedInUrl
    • Speaker: id, speakerName, email, expertise + relationship to SpeakerBio and Talks
    • Room: id, roomName, floor, capacity + relationship to Talks
    • Talk: id, talkTitle, startTime (TIME), durationMinutes + relationships to Speaker, Room
    • Attendee: id, attendeeName, email, company + relationship to Talks
    • Use @JsonIgnore to prevent circular references
  3. Build Repository, Service, Controller for each entity.

  4. Key endpoints:

    • CRUD for all entities
    • PUT /speakers/{id}/bio/{bioId} — assign bio to speaker
    • PUT /playlists/{talkId}/attendees/{attendeeId} — register attendee for talk
    • GET /rooms/{id}/talks — get all talks in a room
  5. Test with Postman — create entities in the right order and verify relationships.

Build from memory first! Only check the demo if you're stuck.


Remember

  • One-to-One: @OneToOne + @JoinColumn on the owner side, mappedBy on the inverse.
  • One-to-Many / Many-to-One: @ManyToOne + @JoinColumn on the "many" side (owner), @OneToMany(mappedBy) on the "one" side.
  • Many-to-Many: @ManyToMany + @JoinTable on the owner, @ManyToMany(mappedBy) on the inverse.
  • The owner side has @JoinColumn or @JoinTable — it controls the FK.
  • The inverse side has mappedBy — it just reads from the other side.
  • Use @JsonIgnore on 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 Set for @ManyToMany to avoid duplicates; use List for @OneToMany.

Additional Resources