Entity adalah jembatan dua arah antara dunia objek dan dunia relasional: satu kelas Java mewakili satu tabel, satu instans mewakili satu baris. Pemetaan yang cermat menentukan kesehatan aplikasi jangka panjang — relasi yang keliru memuat mengundang puluhan query tersembunyi, dan identity yang salah membuat Set menyimpan duplikat. Pada Chapter ini kita petakan entity beserta relasinya, kenal fetch type LAZY, berhadapan dengan masalah N+1, dan bereskan kombinasi equals/hashCode untuk entity.

Anatomi Entity #

Berikut entity Penulis beserta relasi ke Buku — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@Entity
@Table(name = "penulis")
public class Penulis {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String nama;

    @OneToMany(mappedBy = "penulis", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Buku> daftarBuku = new ArrayList<>();

    // konstruktor, getter, setter
}

@Entity
@Table(name = "buku")
public class Buku {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String judul;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "penulis_id")
    private Penulis penulis;

    // konstruktor, getter, setter
}

Anotasi @Id menandai primary key; @GeneratedValue(strategy = IDENTITY) menyerahkan penomoran ke kolom auto-increment database. Sisi @ManyToOne adalah pemilik relasi — kolom foreign key penulis_id hidup di tabel buku; atribut mappedBy = "penulis" di sisi @OneToMany menegaskan bahwa sisi itu hanya cermin, bukan pemilik. Cascade ALL membuat operasi pada penulis menjalar ke bukunya, dan orphanRemoval membersihkan buku yang dilepas dari daftar.

Fetch LAZY dan Masalah N+1 #

Default fetch untuk @ManyToOne adalah EAGER dan untuk @OneToMany adalah LAZY, namun praktik modern menganjurkan LAZY di semua relasi. Fetch LAZY berarti daftar buku tidak dimuat saat penulis dimuat; daftar baru diambil dari database saat pertama kali diakses. Ini menjaga query utama ramping — tetapi membuka jebakan klasik: mengakses lazy collection di dalam loop menghasilkan satu query tambahan per iterasi.

Masalah ini bernama N+1: satu query memuat N penulis, lalu N query lagi memuat buku masing-masing. Mari kita hitung dampaknya secara mekanis:

import java.util.List;

public class SimulasiNPlusSatu {

    static int jumlahQuery = 0;

    record Penulis(long id, String nama) { }

    private static final List<Penulis> PENULIS = List.of(
            new Penulis(1, "Dee Lestari"),
            new Penulis(2, "Tere Liye"),
            new Penulis(3, "Andrea Hirata"));

    public static void main(String[] args) {
        System.out.println("== pendekatan naif: satu query penulis + N query relasi ==");
        jumlahQuery = 0;
        query("SELECT id, nama FROM penulis");
        for (Penulis p : PENULIS) {
            query("SELECT id, judul FROM buku WHERE penulis_id = " + p.id());
        }
        System.out.println("total query: " + jumlahQuery + " (1 + N)");

        System.out.println();
        System.out.println("== pendekatan JOIN FETCH: relasi ikut query pertama ==");
        jumlahQuery = 0;
        query("SELECT p.id, p.nama, b.id, b.judul FROM penulis p "
                + "LEFT JOIN buku b ON b.penulis_id = p.id");
        System.out.println("total query: " + jumlahQuery);

        System.out.println();
        System.out.println("== pendekatan batch: kumpulkan id lalu IN () ==");
        jumlahQuery = 0;
        query("SELECT id, nama FROM penulis");
        query("SELECT id, judul, penulis_id FROM buku WHERE penulis_id IN (1, 2, 3)");
        System.out.println("total query: " + jumlahQuery);
    }

    static void query(String sql) {
        jumlahQuery++;
        System.out.println("  query #" + jumlahQuery + ": " + sql);
    }
}

Output yang Dihasilkan:

text
== pendekatan naif: satu query penulis + N query relasi ==
  query #1: SELECT id, nama FROM penulis
  query #2: SELECT id, judul FROM buku WHERE penulis_id = 1
  query #3: SELECT id, judul FROM buku WHERE penulis_id = 2
  query #4: SELECT id, judul FROM buku WHERE penulis_id = 3
total query: 4 (1 + N)

== pendekatan JOIN FETCH: relasi ikut query pertama ==
  query #1: SELECT p.id, p.nama, b.id, b.judul FROM penulis p LEFT JOIN buku b ON b.penulis_id = p.id
total query: 1

== pendekatan batch: kumpulkan id lalu IN () ==
  query #1: SELECT id, nama FROM penulis
  query #2: SELECT id, judul, penulis_id FROM buku WHERE penulis_id IN (1, 2, 3)
total query: 2

Mari kita bedah ketiga pendekatan pada simulasi tersebut:

  1. Pendekatan naif: query #1 memuat seluruh penulis, lalu loop memanggil satu query per penulis — dengan tiga penulis hasilnya empat query. Tambah penulis menjadi seratus, dan aplikasi menembak 101 query untuk satu halaman daftar.
  2. JOIN FETCH: frasa JPQL LEFT JOIN buku b ON b.penulis_id = p.id memuat relasi dalam satu query — total satu query. @Query("SELECT p FROM Penulis p LEFT JOIN FETCH p.daftarBuku") pada repository adalah bentuk nyatanya.
  3. Pendekatan batch (IN): dua query — kumpulkan id penulis, lalu satu query IN (1, 2, 3) untuk semua buku. Properti spring.jpa.properties.hibernate.default_batch_fetch_size mengaktifkan batching ini secara global tanpa mengubah satu pun query.

Equals dan HashCode untuk Entity #

Entity punya identity database (id) yang berbeda dari identity objek. Instans baru belum punya id; setelah save(), objek yang sama tiba-tiba punya id yang terisi. Kombinasi equals/hashCode berbasis id saja karenanya rapuh: dua instans baru dianggap sama (keduanya id == null) meski beda isi. Praktik yang dianjurkan: equals membandingkan tipe dan id bila terisi, hashCode mengembalikan konstanta — ditampilkan apa adanya:

java
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Penulis lain)) return false;
    return id != null && id.equals(lain.id);
}

@Override
public int hashCode() {
    return getClass().hashCode();
}

hashCode konstan tidak efisien untuk koleksi besar, namun benar secara semantik: identity entity tetap konsisten sebelum dan sesudah persist. Alternatif yang lebih ringan untuk value murni (tanpa identity) adalah record — tetapi record dengan JPA @Entity tidak didukung penuh, sehingga pola di atas tetap relevan untuk entity.

Repository untuk Entity #

Dengan entity di tangan, repository cukup dideklarasikan sebagai interface — PenulisRepository extends JpaRepository<Penulis, Long>. Seluruh CRUD, pagination, dan derived query yang kita bahas pada Chapter Spring Data JPA langsung tersedia untuk Penulis. Satu disiplin penting: setiap operasi tulis sebaiknya berjalan dalam transaksi — penanda @Transactional di service, bukan di repository — agar batas unit of work tetap berada di lapisan yang memahami aturan bisnis. Itulah pokok bahasan Chapter Transaksi Database.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

SimulasiNPlusSatu.java

Unduh berkas
java
import java.util.List;

public class SimulasiNPlusSatu {

    static int jumlahQuery = 0;

    record Penulis(long id, String nama) { }

    private static final List<Penulis> PENULIS = List.of(
            new Penulis(1, "Dee Lestari"),
            new Penulis(2, "Tere Liye"),
            new Penulis(3, "Andrea Hirata"));

    public static void main(String[] args) {
        System.out.println("== pendekatan naif: satu query penulis + N query relasi ==");
        jumlahQuery = 0;
        query("SELECT id, nama FROM penulis");
        for (Penulis p : PENULIS) {
            query("SELECT id, judul FROM buku WHERE penulis_id = " + p.id());
        }
        System.out.println("total query: " + jumlahQuery + " (1 + N)");

        System.out.println();
        System.out.println("== pendekatan JOIN FETCH: relasi ikut query pertama ==");
        jumlahQuery = 0;
        query("SELECT p.id, p.nama, b.id, b.judul FROM penulis p "
                + "LEFT JOIN buku b ON b.penulis_id = p.id");
        System.out.println("total query: " + jumlahQuery);

        System.out.println();
        System.out.println("== pendekatan batch: kumpulkan id lalu IN () ==");
        jumlahQuery = 0;
        query("SELECT id, nama FROM penulis");
        query("SELECT id, judul, penulis_id FROM buku WHERE penulis_id IN (1, 2, 3)");
        System.out.println("total query: " + jumlahQuery);
    }

    static void query(String sql) {
        jumlahQuery++;
        System.out.println("  query #" + jumlahQuery + ": " + sql);
    }
}