Aplikasi nyata jarang punya entity tunggal — biasanya ada jaringan relasi: pengguna punya profil (one-to-one), penulis punya banyak buku (one-to-many), siswa mengambil banyak mata pelajaran dan sebaliknya (many-to-many). JPA menyediakan anotasi @OneToOne, @OneToMany, @ManyToOne, @ManyToMany untuk memetakan relasi tersebut. Pada chapter ini kita akan membedah setiap jenis relasi, strategi fetch EAGER vs LAZY, dan jebakan N+1 query yang sering muncul.

Memilih tipe relasi yang tepat penting karena memengaruhi struktur tabel dan kinerja query. One-to-one cocok untuk relasi yang sifatnya wajib dan eksklusif (satu pengguna, satu profil). One-to-many dipakai saat entity parent bisa punya banyak entity child independen. Many-to-many memerlukan junction table karena tidak ada cara alami menyimpan relasi di salah satu tabel. Kesalahan tipe relasi menyebabkan skema tidak efisien — misalnya, relasi yang seharusnya many-to-many dipaksa menjadi one-to-many dengan duplikasi baris parent.

One-to-One #

Relasi one-to-one terjadi saat satu entity terkait dengan tepat satu entity lain. Contoh: satu Pengguna punya satu Profil. Salah satu tabel menyimpan foreign key ke tabel lain.

kotlin One-to-One dengan FK
@Entity data class Pengguna(
    @Id @GeneratedValue val id: Long = 0,
    val nama: String,
    @OneToOne(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val profil: Profil? = null
)

@Entity data class Profil(
    @Id @GeneratedValue val id: Long = 0,
    val bio: String,
    @OneToOne @JoinColumn(name = "user_id") val user: Pengguna? = null
)

Sisi yang menyimpan FK adalah owning side (Profil.user). Sisi lain (Pengguna.profil) memakai mappedBy = "user" sebagai inverse side. Hanya satu sisi yang menyimpan FK di database.

One-to-Many dan Many-to-One #

Relasi one-to-many di sisi parent dan many-to-one di sisi child selalu berpasangan. Contoh: satu Penulis punya banyak Buku. FK penulis_id disimpan di tabel buku.

kotlin One-to-Many / Many-to-One
@Entity data class Penulis(
    @Id @GeneratedValue val id: Long = 0,
    val nama: String,
    @OneToMany(mappedBy = "penulis", fetch = FetchType.LAZY)
    val buku: List<Buku> = emptyList()
)

@Entity data class Buku(
    @Id @GeneratedValue val id: Long = 0,
    val judul: String,
    @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "penulis_id")
    val penulis: Penulis? = null
)

Default fetch untuk @ManyToOne dan @OneToOne adalah EAGER; untuk @OneToMany dan @ManyToMany adalah LAZY. Sebagian besar kasus disarankan mengubah @ManyToOne ke LAZY eksplisit.

Many-to-Many #

Relasi many-to-many memerlukan junction table. Contoh: siswa dan mata pelajaran. Satu siswa bisa ambil banyak mapel; satu mapel diambil banyak siswa.

kotlin Many-to-Many
@Entity data class Siswa(
    @Id @GeneratedValue val id: Long = 0,
    val nama: String,
    @ManyToMany @JoinTable(
        name = "siswa_mapel",
        joinColumns = [JoinColumn(name = "siswa_id")],
        inverseJoinColumns = [JoinColumn(name = "mapel_id")]
    )
    val mapel: MutableList<MataPelajaran> = mutableListOf()
)

@Entity data class MataPelajaran(
    @Id @GeneratedValue val id: Long = 0,
    val nama: String,
    @ManyToMany(mappedBy = "mapel") val siswa: List<Siswa> = emptyList()
)

Hibernate membuat tabel siswa_mapel dengan dua FK. Operasi add di sisi owning (siswa.mapel.add(mapel)) akan menambah baris di junction table saat flush.

Strategi Fetch: EAGER vs LAZY #

FetchSaat dimuatCocok untuk
EAGERSaat entity induk dimuat (JOIN atau query kedua)Relasi yang hampir selalu dibutuhkan
LAZYSaat properti relasi pertama diaksesRelasi yang sering tidak dibutuhkan

EAGER default untuk @ManyToOne dan @OneToOne berisiko memuat seluruh grafik entity tanpa perlu — efek cartesian product atau N+1 query. Best practice: ubah semua fetch ke LAZY kecuali ada alasan kuat.

Kode Lengkap: Tiga Jenis Relasi #

Kode berikut menjalankan ketiga jenis relasi dengan data sederhana di SQLite via JDBC, lengkap dengan demonstrasi EAGER (JOIN) vs LAZY (query terpisah).

kotlin
// entities_dan_relationships.kt
// Mendemonstrasikan relasi entity: one-to-one, one-to-many, many-to-many,
// dan strategi fetch (EAGER vs LAZY). Memakai SQLite via JDBC dengan
// pola repository sederhana untuk setiap jenis relasi.

import java.sql.Connection
import java.sql.DriverManager

data class Pengguna(val id: Int, val nama: String, val profilId: Int? = null)
data class Profil(val id: Int, val bio: String, val userId: Int)
data class Penulis(val id: Int, val nama: String)
data class Buku(val id: Int, val judul: String, val penulisId: Int)
data class Siswa(val id: Int, val nama: String)
data class MataPelajaran(val id: Int, val nama: String)

// Repository untuk mendemokan berbagai relasi.
class RelasiRepo(private val conn: Connection) {

    fun createSchema() {
        conn.createStatement().use { it.execute(
            """CREATE TABLE pengguna (id INTEGER PRIMARY KEY, nama TEXT, profil_id INTEGER UNIQUE,
               FOREIGN KEY (profil_id) REFERENCES profil(id))"""
        ) }
        conn.createStatement().use { it.execute(
            """CREATE TABLE profil (id INTEGER PRIMARY KEY, bio TEXT, user_id INTEGER,
               FOREIGN KEY (user_id) REFERENCES pengguna(id))"""
        ) }
        conn.createStatement().use { it.execute(
            """CREATE TABLE penulis (id INTEGER PRIMARY KEY, nama TEXT)"""
        ) }
        conn.createStatement().use { it.execute(
            """CREATE TABLE buku (id INTEGER PRIMARY KEY, judul TEXT, penulis_id INTEGER,
               FOREIGN KEY (penulis_id) REFERENCES penulis(id))"""
        ) }
        conn.createStatement().use { it.execute(
            """CREATE TABLE siswa (id INTEGER PRIMARY KEY, nama TEXT)"""
        ) }
        conn.createStatement().use { it.execute(
            """CREATE TABLE mata_pelajaran (id INTEGER PRIMARY KEY, nama TEXT)"""
        ) }
        conn.createStatement().use { it.execute(
            """CREATE TABLE siswa_mapel (siswa_id INTEGER, mapel_id INTEGER,
               PRIMARY KEY (siswa_id, mapel_id),
               FOREIGN KEY (siswa_id) REFERENCES siswa(id),
               FOREIGN KEY (mapel_id) REFERENCES mata_pelajaran(id))"""
        ) }
    }

    // === One-to-One: pengguna <-> profil ===
    fun seedOneToOne() {
        conn.prepareStatement("INSERT INTO pengguna(id, nama, profil_id) VALUES(?, ?, NULL)").use { ps ->
            listOf(1 to "Budi", 2 to "Siti").forEach { (id, nama) ->
                ps.setInt(1, id); ps.setString(2, nama); ps.executeUpdate()
            }
        }
        conn.prepareStatement("INSERT INTO profil(id, bio, user_id) VALUES(?, ?, ?)").use { ps ->
            ps.setInt(1, 1); ps.setString(2, "Belajar Kotlin"); ps.setInt(3, 1); ps.executeUpdate()
            ps.setInt(1, 2); ps.setString(2, "Penggemar kopi"); ps.setInt(3, 2); ps.executeUpdate()
        }
        // Update FK pengguna.profil_id
        conn.prepareStatement("UPDATE pengguna SET profil_id = ? WHERE id = ?").use { ps ->
            ps.setInt(1, 1); ps.setInt(2, 1); ps.executeUpdate()
            ps.setInt(1, 2); ps.setInt(2, 2); ps.executeUpdate()
        }
    }

    // EAGER fetch: load profil sekaligus saat ambil pengguna
    fun findPenggunaEager(id: Int): Pengguna? {
        val sql = """SELECT u.id, u.nama, p.id AS pid, p.bio
                     FROM pengguna u LEFT JOIN profil p ON p.user_id = u.id
                     WHERE u.id = ?"""
        conn.prepareStatement(sql).use { ps ->
            ps.setInt(1, id)
            ps.executeQuery().use { rs ->
                if (rs.next()) {
                    val profilId = rs.getInt("pid")
                    val bio = rs.getString("bio")
                    println("  [EAGER] profil di-load: id=$profilId, bio=$bio")
                    return Pengguna(rs.getInt("id"), rs.getString("nama"), profilId)
                }
            }
        }
        return null
    }

    // LAZY fetch: load profil hanya saat diakses
    fun findPenggunaLazy(id: Int): Pengguna? {
        conn.prepareStatement("SELECT id, nama FROM pengguna WHERE id = ?").use { ps ->
            ps.setInt(1, id)
            ps.executeQuery().use { rs ->
                if (rs.next()) return Pengguna(rs.getInt("id"), rs.getString("nama"), null)
            }
        }
        return null
    }

    fun loadProfil(userId: Int): Profil? {
        println("  [LAZY] query profil dijalankan saat akses")
        conn.prepareStatement("SELECT id, bio, user_id FROM profil WHERE user_id = ?").use { ps ->
            ps.setInt(1, userId)
            ps.executeQuery().use { rs ->
                if (rs.next()) return Profil(rs.getInt("id"), rs.getString("bio"), rs.getInt("user_id"))
            }
        }
        return null
    }

    // === One-to-Many: penulis -> buku ===
    fun seedOneToMany() {
        conn.prepareStatement("INSERT INTO penulis(id, nama) VALUES(?, ?)").use { ps ->
            ps.setInt(1, 1); ps.setString(2, "Pak Budi"); ps.executeUpdate()
            ps.setInt(1, 2); ps.setString(2, "Bu Siti"); ps.executeUpdate()
        }
        conn.prepareStatement("INSERT INTO buku(id, judul, penulis_id) VALUES(?, ?, ?)").use { ps ->
            ps.setInt(1, 1); ps.setString(2, "Kotlin Dasar"); ps.setInt(3, 1); ps.executeUpdate()
            ps.setInt(1, 2); ps.setString(2, "Coroutines"); ps.setInt(3, 1); ps.executeUpdate()
            ps.setInt(1, 3); ps.setString(2, "Compose"); ps.setInt(3, 2); ps.executeUpdate()
        }
    }

    fun findBukuByPenulis(penulisId: Int): List<Buku> {
        val result = mutableListOf<Buku>()
        conn.prepareStatement("SELECT id, judul, penulis_id FROM buku WHERE penulis_id = ? ORDER BY id").use { ps ->
            ps.setInt(1, penulisId)
            ps.executeQuery().use { rs ->
                while (rs.next()) result.add(Buku(rs.getInt("id"), rs.getString("judul"), rs.getInt("penulis_id")))
            }
        }
        return result
    }

    // === Many-to-Many: siswa <-> mata_pelajaran via junction table ===
    fun seedManyToMany() {
        conn.prepareStatement("INSERT INTO siswa(id, nama) VALUES(?, ?)").use { ps ->
            ps.setInt(1, 1); ps.setString(2, "Andi"); ps.executeUpdate()
            ps.setInt(1, 2); ps.setString(2, "Dewi"); ps.executeUpdate()
        }
        conn.prepareStatement("INSERT INTO mata_pelajaran(id, nama) VALUES(?, ?)").use { ps ->
            ps.setInt(1, 1); ps.setString(2, "Matematika"); ps.executeUpdate()
            ps.setInt(1, 2); ps.setString(2, "Fisika"); ps.executeUpdate()
        }
        conn.prepareStatement("INSERT INTO siswa_mapel(siswa_id, mapel_id) VALUES(?, ?)").use { ps ->
            ps.setInt(1, 1); ps.setInt(2, 1); ps.executeUpdate()
            ps.setInt(1, 1); ps.setInt(2, 2); ps.executeUpdate()
            ps.setInt(1, 2); ps.setInt(2, 1); ps.executeUpdate()
        }
    }

    fun findMapelBySiswa(samaId: Int): List<MataPelajaran> {
        val result = mutableListOf<MataPelajaran>()
        val sql = """SELECT m.id, m.nama FROM mata_pelajaran m
                    JOIN siswa_mapel sm ON sm.mapel_id = m.id
                    WHERE sm.siswa_id = ? ORDER BY m.id"""
        conn.prepareStatement(sql).use { ps ->
            ps.setInt(1, samaId)
            ps.executeQuery().use { rs ->
                while (rs.next()) result.add(MataPelajaran(rs.getInt("id"), rs.getString("nama")))
            }
        }
        return result
    }
}

fun main() {
    Class.forName("org.sqlite.JDBC")
    val url = "jdbc:sqlite:file::memory:?cache=shared"
    val keeper: Connection = DriverManager.getConnection(url)

    DriverManager.getConnection(url).use { conn ->
        val repo = RelasiRepo(conn)
        repo.createSchema()
        repo.seedOneToOne()
        repo.seedOneToMany()
        repo.seedManyToMany()

        println("=== One-to-One: pengguna <-> profil ===")
        println("EAGER (JOIN langsung):")
        val u1 = repo.findPenggunaEager(1)
        println("  Hasil: $u1")
        println("LAZY (query terpisah):")
        val u2 = repo.findPenggunaLazy(2)
        println("  Hasil awal: $u2")
        val profil = repo.loadProfil(2)
        println("  Setelah loadProfil: $profil")

        println("\n=== One-to-Many: penulis -> buku ===")
        val bukuBudi = repo.findBukuByPenulis(1)
        println("Buku Pak Budi:")
        bukuBudi.forEach { println("  - $it") }
        val bukuSiti = repo.findBukuByPenulis(2)
        println("Buku Bu Siti:")
        bukuSiti.forEach { println("  - $it") }

        println("\n=== Many-to-Many: siswa <-> mata_pelajaran ===")
        val mapelAndi = repo.findMapelBySiswa(1)
        println("Mata pelajaran Andi:")
        mapelAndi.forEach { println("  - $it") }
        val mapelDewi = repo.findMapelBySiswa(2)
        println("Mata pelajaran Dewi:")
        mapelDewi.forEach { println("  - $it") }

        println("\n=== Fetch types ===")
        println("| Tipe  | Default        | Karakteristik                              |")
        println("|-------|----------------|--------------------------------------------|")
        println("| EAGER | @ManyToOne      | Load relasi saat ambil entity induk         |")
        println("| LAZY  | @OneToMany     | Load relasi saat pertama diakses            |")
        println("| LAZY  | @ManyToMany    | Pakai junction table, load saat diakses     |")
        println("| EAGER | @OneToOne      | Bisa di-override ke LAZY via fetch=LAZY    |")

        println("\n=== Jebakan N+1 ===")
        println("Tanpa JOIN/fetch, ambil N penulis + 1 buku per penulis = N+1 query.")
        println("Solusi: JOIN FETCH di JPQL atau @EntityGraph.")
    }

    keeper.close()
}

Output yang Dihasilkan:

text
=== One-to-One: pengguna <-> profil ===
EAGER (JOIN langsung):
  [EAGER] profil di-load: id=1, bio=Belajar Kotlin
  Hasil: Pengguna(id=1, nama=Budi, profilId=1)
LAZY (query terpisah):
  Hasil awal: Pengguna(id=2, nama=Siti, profilId=null)
  [LAZY] query profil dijalankan saat akses
  Setelah loadProfil: Profil(id=2, bio=Penggemar kopi, userId=2)

=== One-to-Many: penulis -> buku ===
Buku Pak Budi:
  - Buku(id=1, judul=Kotlin Dasar, penulisId=1)
  - Buku(id=2, judul=Coroutines, penulisId=1)
Buku Bu Siti:
  - Buku(id=3, judul=Compose, penulisId=2)

=== Many-to-Many: siswa <-> mata_pelajaran ===
Mata pelajaran Andi:
  - MataPelajaran(id=1, nama=Matematika)
  - MataPelajaran(id=2, nama=Fisika)
Mata pelajaran Dewi:
  - MataPelajaran(id=1, nama=Matematika)

=== Fetch types ===
| Tipe  | Default        | Karakteristik                              |
|-------|----------------|--------------------------------------------|
| EAGER | @ManyToOne      | Load relasi saat ambil entity induk         |
| LAZY  | @OneToMany     | Load relasi saat pertama diakses            |
| LAZY  | @ManyToMany    | Pakai junction table, load saat diakses     |
| EAGER | @OneToOne      | Bisa di-override ke LAZY via fetch=LAZY    |

=== Jebakan N+1 ===
Tanpa JOIN/fetch, ambil N penulis + 1 buku per penulis = N+1 query.
Solusi: JOIN FETCH di JPQL atau @EntityGraph.

Jebakan N+1 Query #

Saat memuat daftar entity dengan relasi LAZY, akses relasi tiap entity menghasilkan 1 + N query:

kotlin N+1 query
val penulisList = em.createQuery("SELECT p FROM Penulis p", Penulis::class.java).resultList
// Query 1: SELECT * FROM penulis

for (p in penulisList) {
    println(p.buku.size)  // Query 2..N+1: SELECT * FROM buku WHERE penulis_id = ?
}

Solusi paling umum adalah JOIN FETCH di JPQL:

kotlin JOIN FETCH untuk cegah N+1
val penulisList = em.createQuery(
    "SELECT DISTINCT p FROM Penulis p LEFT JOIN FETCH p.buku",
    Penulis::class.java
).resultList
// Hanya 1 query dengan JOIN

Alternatif modern: @EntityGraph annotation di Spring Data JPA yang memungkinkan menentukan relasi mana yang ikut dimuat secara dinamis.

Referensi Lanjutan #

Selanjutnya #

Schema database tidak statis — seiring waktu kita menambah kolom, tabel, dan indeks. Chapter Database Migrations akan memperkenalkan Flyway dan Liquibase untuk mengelola evolusi skema secara terkontrol.