JPA & Hibernate: Memetakan Java ke Database
Di artikel sebelumnya kita bicara database relasional & SQL: tabel, baris, kolom, dan JOIN. Sekarang pertanyaannya: di Java kita tidak punya tabel — kita punya objek. Bagaimana cara menyimpan objek Order dengan isi List<OrderItem> ke dalam database yang hanya mengerti baris dan kolom? Jawabannya adalah ORM (Object-Relational Mapping), dan di ekosistem Java jawaban paling umum adalah JPA sebagai spesifikasi dan Hibernate sebagai implementasinya.
Masalah yang Dipecahkan ORM
Dunia Java dan dunia database tidak selaras secara alami. Java berpikir dalam objek dengan tipe, perilaku, dan relasi ber-graph (objek saling memanggil metode satu sama lain). Database relasional berpikir dalam baris dan kolom yang flat, tanpa perilaku. Perbedaan sudut pandang ini dikenal sebagai impedance mismatch — "ketidakcocokan impedansi", istilah pinjaman dari elektronika.
| Dimensi | Dunia Java (Objek) | Dunia Database (Relasional) |
|---|---|---|
| Unit data | Objek / instance kelas | Baris pada tabel |
| Identitas | Referensi objek di memori | Primary Key |
| Relasi | Referensi langsung antar objek (order.getUser()) | Foreign Key + JOIN |
| Perilaku | Objek punya method | Tabel tidak punya apa-apa |
| Hierarchy | Inheritance antar kelas | Tidak ada konsep inheritance (kecuali tabel per-kelas) |
ORM adalah jembatan di antara keduanya: kita tetap menulis objek Java, lalu framework menerjemahkannya menjadi SQL (INSERT/SELECT/UPDATE/DELETE) dan menerjemahkan hasil query kembali menjadi objek.
Java objek ──► ORM ──► SQL/baris ──► Database
order.getItems() SELECT ... rows
objek dibuat ◄───── hasil query dipetakan kembali
Tiga Lapisan yang Sering Tertukar
Banyak pemula menganggap JPA, Hibernate, dan Spring Data JPA adalah hal yang sama. Sebenarnya ini tiga lapisan berbeda:
┌───────────────────────────────────────┐
│ Kode aplikasi Anda (panggil repo) │
├───────────────────────────────────────┤
│ Spring Data JPA — abstraksi repository│
├───────────────────────────────────────┤
│ JPA — spesifikasi, hanya kontrak │
├───────────────────────────────────────┤
│ Hibernate — implementasi yang nyata │
├───────────────────────────────────────┤
│ JDBC — koneksi ke PostgreSQL/MySQL │
└───────────────────────────────────────┘
| Lapisan | Jenis | Peran |
|---|---|---|
| JPA (Jakarta Persistence) | Spesifikasi | Menetapkan "aturan main": anotasi seperti @Entity, API EntityManager, konsep lifecycle. Hanya kontrak, tidak ada kode yang jalan. |
| Hibernate | Implementasi | Menjalankan aturan itu: membaca anotasi, menulis SQL, mengelola cache dan persistence context. |
| Spring Data JPA | Abstraksi | Di atas JPA/Hibernate, menyediakan Repository generik sehingga kita cukup menulis interface — kode SQL-nya diturunkan dari nama method. |
Intuisi cepat: JPA itu seperti standar colokan listrik, Hibernate seperti stopkontak yang benar-benar terpasang di dinding, dan Spring Data JPA seperti saklar yang memudahkan Anda menyalakan lampu tanpa menyentuh kabelnya.
Entity: Objek yang Bisa "Dibekukan" ke Database
Entity adalah kelas Java yang diberi tahu JPA untuk disimpan ke database. Satu entity = satu tabel, satu instance = satu baris.
import jakarta.persistence.*;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "price", nullable = false)
private BigDecimal price;
@Column(name = "stock", nullable = false)
private Integer stock;
@Column(name = "is_active", nullable = false)
private boolean active = true;
public Product() {}
public Product(String name, BigDecimal price, Integer stock) {
this.name = name;
this.price = price;
this.stock = stock;
}
// getter & setter ...
}
Mari kita bedah anotasi intinya:
| Anotasi | Fungsi |
|---|---|
@Entity | Menandai kelas sebagai entity yang dikelola JPA. Wajib punya konstruktor tanpa argumen. |
@Table(name = "products") | Memetakan kelas ke tabel products. Boleh dihilangkan kalau nama tabel sama dengan nama kelas. |
@Id | Menandai primary key. Wajib ada — JPA menolak entity tanpa identitas. |
@GeneratedValue(strategy = GenerationType.IDENTITY) | Id diisi otomatis database (auto-increment ala BIGSERIAL). |
@Column | Mengatur detail kolom: name (nama kolom), nullable (boleh NULL?), length (panjang VARCHAR), unique (constraint unik). |
Contoh Entity User
@Entity
@Table(name = "users", uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(name = "full_name", nullable = false, length = 255)
private String fullName;
@Column(nullable = false)
private String passwordHash;
// getter & setter ...
}
Perhatikan passwordHash: kita menyimpan hash dari kata sandi, bukan kata sandi asli — ini pembahasan yang detailnya ada di password hashing dengan BCrypt.
Memetakan Relasi Antar Tabel
Di artikel SQL relasi ditangani dengan Foreign Key dan JOIN. Di JPA, relasi ditangani dengan anotasi pada field. Empat pola yang perlu dikuasai:
| Anotasi | Relasi | Contoh dunia nyata |
|---|---|---|
@OneToOne | Satu-ke-satu | Satu UserProfile milik satu User |
@ManyToOne | Banyak-ke-satu (sisi pemilik FK) | Banyak OrderItem milik satu Order |
@OneToMany | Satu-ke-banyak (sisi koleksi) | Satu Order berisi banyak OrderItem |
@ManyToMany | Banyak-ke-banyak | User punya banyak Role, Role dipakai banyak User |
@OneToMany / @ManyToOne — pasangan sejati
Satu Order berisi banyak OrderItem, dan setiap OrderItem merujuk balik ke Order. Inilah pasangan anotasi yang paling sering dipakai.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "order") // sisi "satu"
private List<OrderItem> items = new ArrayList<>();
// ...
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // sisi "banyak", pemilik FK
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(nullable = false)
private String productName;
@Column(nullable = false)
private Integer quantity;
// ...
}
| Konsep | Penjelasan |
|---|---|
mappedBy = "order" | Mengatakan: "relasi ini sudah dikelola oleh field order di sisi OrderItem". Kolom FK order_id dibuat di sisi banyak. |
@JoinColumn(name = "order_id") | Menentukan nama kolom Foreign Key di tabel order_items. |
| Hanya satu pemilik | JPA mewajibkan tepat satu sisi menjadi "pemilik" relasi; sisi lainnya memakai mappedBy. |
@ManyToMany — butuh tabel join
Banyak-ke-banyak tidak bisa diwakili satu kolom FK. Database membutuhkan tabel join — tabel perantara berisi dua FK. Di JPA, Hibernate otomatis membuatnya dengan @JoinTable.
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
// ...
}
users (1) ────< (N) user_roles >──── (M) roles
id user_id FK id
role_id FK name
Parameter @JoinTable | Arti |
|---|---|
name = "user_roles" | Nama tabel join yang dibuat di database. |
joinColumns | FK ke tabel pemilik (users.id). |
inverseJoinColumns | FK ke tabel lawan (roles.id). |
Gunakan Set<Role> daripada List<Role> untuk ManyToMany: Set menjamin tidak ada duplikasi dan menghindari perilaku aneh JPA pada koleksi yang sama.
Fetch Strategy: EAGER vs LAZY
Setiap relasi punya strategi kapan data pasangannya diambil dari database.
| Strategi | Perilaku | Kapan dipakai |
|---|---|---|
EAGER | Data pasangan langsung dimuat saat entity utama di-load, tanpa menunggu diakses. | Relasi kecil yang hampir selalu dibutuhkan bersamaan. |
LAZY | Data pasangan baru diambil saat getter-nya dipanggil (dan transaksi masih terbuka). | Relasi koleksi besar; ini default di Hibernate. |
Hibernate menetapkan default: @OneToMany dan @ManyToMany itu LAZY; @OneToOne dan @ManyToOne itu EAGER. Karena perilaku EAGER mudah menimbulkan query besar yang tidak diinginkan, praktik yang disarankan adalah memaksa semuanya LAZY lalu mengambil data sesuai kebutuhan.
EAGER: saat order di-load, items langsung ikut diambil (bisa 1+ query)
LAZY: hanya order yang diambil; items baru dimuat saat getter dipanggil
(misal: SELECT * FROM order_items WHERE order_id = ?)
Aturan praktis:
EAGERuntuk relasi yang Anda yakin selalu dibutuhkan dan berukuran kecil. Untuk koleksi (list of items, tags, dsb.) hampir selaluLAZY— jangan buang waktu mengambil ribuan baris yang tidak akan pernah dibaca.
Cascade: Kapan Aman, Kapan Berbahaya
Cascade mengatur: saat entity induk di-save, di-merge, atau di-hapus, apakah operasi yang sama ikut dijalankan ke relasinya.
| CascadeType | Dampak |
|---|---|
PERSIST | Menyimpan entity baru juga menyimpan anaknya (misal simpan Order → OrderItem ikut tersimpan). |
REMOVE | Menghapus induk juga menghapus anaknya. |
ALL | Gabungan semua operasi (PERSIST + MERGE + REMOVE + REFRESH + DETACH). |
MERGE / REFRESH / DETACH | Operasi lain yang jarang diubah dari default. |
Contoh aman — Order memang hidup dan mati bersama OrderItem-nya. Saat order dibatalkan dan dihapus, item-itemnya juga harus lenyap:
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
orphanRemoval = true menambah jaminan: OrderItem yang sudah dikeluarkan dari list akan otomatis dihapus — tidak meninggalkan baris yatim.
Contoh berbahaya — jangan pernah cascade = ALL pada relasi yang entitasnya hidup mandiri. Hapus Category yang punya banyak Product tidak boleh ikut menghapus produknya; produk tetap ada, hanya kategori yang pergi:
// BERBAHAYA: cascade ALL → menghapus kategori ikut menghapus semua produk!
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
private List<Product> products;
// AMAN: kategori boleh dihapus, produk tetap hidup
@OneToMany(mappedBy = "category")
private List<Product> products;
Aturan praktis cascade: cascade untuk komposisi (induk yang menciptakan dan memiliki anak sepenuhnya), bukan untuk agregasi (anak yang bisa hidup sendiri). Order→OrderItem adalah komposisi; Category→Product adalah agregasi.
Persistence Context & Entity Lifecycle
Persistence context adalah "ruang kerja" JPA: kumpulan entity yang sedang dikelola dalam satu transaksi. Setiap perubahan pada entity yang managed dipantau; saat transaksi di-flush, perbedaannya otomatis ditulis ke database tanpa perlu save lagi.
Setiap instance entity berada di salah satu dari empat status:
| Status | Arti | Ciri khas |
|---|---|---|
transient | Baru new, belum dikenal JPA. | Belum punya id, belum terhubung ke database. |
managed | Terlampir ke persistence context. | Perubahan field otomatis di-flush; masih ada saat transaksi berlangsung. |
detached | Sudah pernah dikelola, tapi keluar dari persistence context (transaksi selesai). | Masih punya id, tapi perubahannya tidak lagi dipantau. |
removed | Dijadwalkan untuk dihapus. | Baris akan dihapus saat flush. |
new Entity()
│
▼
transient ──persist()──► managed ──flush/commit──► database
▲ │
│ transaksi selesai
│ ▼
└────────────── detached ──remove()──► removed ──flush──► terhapus
Contoh paling nyata dari sifat managed adalah tanpa pemanggilan save eksplisit:
@Transactional
public void updateOrderStatus(Long orderId, String status) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(status); // cukup ubah field ...
} // ... transaksi commit = UPDATE dikirim otomatis
JPA membandingkan entity saat commit, dan mengirim UPDATE untuk yang berubah. Inilah dirty checking — fitur yang menghemat banyak kode boilerplate, sekaligus alasan kenapa mengubah entity detached di luar transaksi tidak berpengaruh apa pun ke database.
Spring Data JPA: Repository Tanpa Kode SQL
Spring Data JPA mengambil alih bagian paling membosankan: daripada menulis OrderRepository dengan belasan method save, findById, findAll, kita cukup mendeklarasikan interface.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContainingIgnoreCase(String name);
long countByRole(Role role);
}
JpaRepository<User, Long> sudah menyediakan method CRUD bawaan: save, findById, findAll, deleteById, existsById, dan count. Kuncinya, nama method adalah kontrak: Spring mem-parsing nama method dan menurunkan query SQL-nya.
| Nama method | Bagian yang di-parsing | Query yang dihasilkan |
|---|---|---|
findByEmail | Email → kolom email | SELECT * FROM users WHERE email = ? |
findByNameContainingIgnoreCase | Name, Containing, IgnoreCase | WHERE UPPER(name) LIKE UPPER('%' + ? + '%') |
countByRole | count, Role | SELECT COUNT(*) FROM users WHERE role = ? |
deleteByEmail | delete | DELETE FROM users WHERE email = ? |
Query Kustom: JPQL vs Native SQL
Kadang nama method tidak cukup, misalnya untuk JOIN yang kompleks. Ada dua cara menulis query sendiri:
JPQL (Jakarta Persistence Query Language) — menulis query pada entity, bukan pada tabel. Sintaksnya mirip SQL tetapi nama kelas dan field Java yang dipakai, sehingga portabel antar database.
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") String status);
Native query — SQL mentah sesuai database yang dipakai. Kuat untuk kasus yang memakai fitur spesifik database, tapi membuat kode Anda kurang portabel.
@Query(value = "SELECT o.* FROM orders o WHERE o.total > :min " +
"ORDER BY o.created_at DESC LIMIT 20", nativeQuery = true)
List<Order> findRecentBigOrders(@Param("min") BigDecimal min);
| JPQL | Native | |
|---|---|---|
| Target | Entity (nama kelas & field) | Tabel dan kolom SQL asli |
| Portabilitas | Berjalan di semua database | Tergantung SQL database tertentu |
| Hasil | Dikembalikan sebagai entity/objek | Perlu mapping manual bila bukan entity |
Praktik umum: pakai derived query untuk kebutuhan sederhana, JPQL untuk logika yang berhubungan dengan relasi entity, dan native hanya ketika JPQL benar-benar tidak sanggup — atau saat performa sangat menuntut.
Jebakan N+1: Musuh Terbesar Performa ORM
Masalah N+1 terjadi ketika untuk menampilkan N baris, aplikasi justru menjalankan N + 1 query: 1 query untuk list utama, lalu N query lagi — satu per baris — saat relasi yang lazy diakses.
Kode berikut terlihat polos, tetapi sangat mahal:
@Transactional(readOnly = true)
public List<OrderSummary> listOrders() {
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
int total = order.getItems().size(); // N query! satu per order
...
}
}
SELECT * FROM orders; (1 query)
SELECT * FROM order_items WHERE order_id = 1; (N query, satu per baris)
SELECT * FROM order_items WHERE order_id = 2;
...
N+1 total
Mengapa terjadi? Karena items bertipe LAZY: setiap kali getter-nya dipanggil, Hibernate menjalankan query baru untuk order itu. Dengan 1.000 order berarti 1.001 query.
Solusi: Gabungkan Relasi dalam Satu Query
JOIN FETCH — gabungkan data items dalam satu SQL menggunakan JOIN (JPQL):
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();
@EntityGraph — deklaratif, tetap memakai nama method sederhana:
@EntityGraph(attributePaths = "items")
List<Order> findAllWithItems(); // di interface yang sama
@BatchSize — kalau query tetap lazy, pangkas jumlah query menjadi beberapa batch (misal satu query untuk 20 id):
@OneToMany(mappedBy = "order")
@BatchSize(size = 20)
private List<OrderItem> items = new ArrayList<>();
Setelah diperbaiki dengan JOIN FETCH atau @EntityGraph, total query untuk 1.000 order turun menjadi 1 query saja. Memantau jumlah query (misalnya lewat log Hibernate show_sql) seharusnya jadi kebiasaan sebelum merilis fitur yang menampilkan koleksi.
Mengatur Skema: ddl-auto
Hibernate bisa mengubah skema database sesuai entity — dikontrol lewat properti spring.jpa.hibernate.ddl-auto:
| Nilai | Perilaku | Kapan dipakai |
|---|---|---|
create | Hapus & buat ulang tabel setiap startup. | Sekali-sekali untuk prototipe lokal. |
update | Menambah kolom/tabel baru yang belum ada. | Tidak untuk production (tidak bisa menghapus, bisa salah duga). |
validate | Memeriksa entity cocok dengan tabel yang ada, error bila tidak. | Aman untuk production. |
none | Tidak menyentuh skema sama sekali. | Production dengan migrasi terkelola. |
Untuk production, skema sebaiknya dikelola lewat migrasi database seperti Flyway — urutan SQL yang di-versioning, di-review, dan dapat di-rollback, bukan ditebak otomatis oleh ORM.
Best Practice
- Jangan panggil getter berantai di luar transaksi.
order.getUser().getEmail()bisa melemparLazyInitializationExceptionketika session sudah ditutup, atau diam-diam memicu query di tempat yang tidak terduga. Ambil data yang Anda butuhkan di dalam@Transactional, lalu bungkus ke DTO. - Gunakan DTO untuk respons API. Jangan serialisasi entity langsung ke JSON: Anda ikut membocorkan
passwordHash, memicu lazy loading saat serialisasi, dan menggandeng entity ke logika presentasi. Kirim objek kecil berisi field yang dibutuhkan saja. - Letakkan
@Transactionaldi service, bukan di controller atau di repository. Transaksi harus membungkus seluruh alur bisnis (misal ambil stok + kurangi stok + simpan order), bukan satu query. Detail lebih dalam ada di artikel transaksi database. - Selalu
LAZYuntuk koleksi, dan gabungkan data denganJOIN FETCH/@EntityGraphsecara sadar — jangan serahkan ke EAGER. - Pelajari query yang dihasilkan. Aktifkan log SQL saat development dan biasakan menghitung jumlah query per request. Angka N+1 tidak akan terlihat kalau Anda tidak pernah melihatnya.
- Jangan cascade sembarangan — cascade untuk komposisi (
Order→OrderItem), bukan untuk entitas yang hidup mandiri.
Ringkasan
ORM menjembatani impedance mismatch antara objek Java dan tabel database. JPA adalah spesifikasi, Hibernate implementasinya, dan Spring Data JPA membuat repository cukup berupa interface dengan nama method yang menjelaskan query-nya. Entity dipetakan dengan @Entity, @Id, dan @Column; relasi dengan @OneToOne, @OneToMany, @ManyToMany — selalu ingat sisi mana yang pemilik FK dan kapan butuh tabel join. Pahami lifecycle transient → managed → detached → removed dan fenomena N+1 yang menghantui lazy loading; atasi dengan JOIN FETCH, @EntityGraph, atau @BatchSize. Dan ingat: biarkan migrasi skema dikelola Flyway, bukan ditebak ddl-auto.
Lanjut membaca
- Jakarta Persistence (JPA) — official tutorial — referensi spesifikasi JPA dari sumber pertama.
- Hibernate ORM Documentation — dokumentasi versi 6.6.
- Hibernate User Guide — panduan lengkap yang lebih teknis.
- Spring Data JPA Reference — dokumentasi resmi repository, derived query, dan
@EntityGraph. - Dasar Database Relasional & SQL — fondasi tabel, constraint, dan JOIN yang dipakai artikel ini.
- Transaksi Database — ke mana
@Transactionalbekerja, ACID, dan isolation level.