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.
@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.
@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.
@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 #
| Fetch | Saat dimuat | Cocok untuk |
|---|---|---|
EAGER | Saat entity induk dimuat (JOIN atau query kedua) | Relasi yang hampir selalu dibutuhkan |
LAZY | Saat properti relasi pertama diakses | Relasi 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).
// 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:
=== 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:
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:
val penulisList = em.createQuery(
"SELECT DISTINCT p FROM Penulis p LEFT JOIN FETCH p.buku",
Penulis::class.java
).resultList
// Hanya 1 query dengan JOINAlternatif modern: @EntityGraph annotation di Spring Data JPA yang memungkinkan menentukan relasi mana yang ikut dimuat secara dinamis.
Referensi Lanjutan #
- Hibernate ORM — dokumentasi resmi entity dan relationship mapping Hibernate
- Hibernate User Guide
— panduan
@OneToMany,@ManyToOne, dan kaskade pada Hibernate
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.