Tampilan
Kenapa penting
Data bisnis jarang berdiri sendiri: pesanan punya banyak item, setiap item menunjuk produk, produk masuk beberapa kategori. Di database, hubungan ini berupa foreign key; di JPA, berupa field yang menunjuk entity lain. Pemetaan relasi adalah sumber bug JPA paling umum di tempat kerja: item yang tersimpan tanpa order_id, produk ikut terhapus saat item dihapus, LazyInitializationException di production, atau satu endpoint yang memicu ratusan query. Memahami sisi pemilik, cascade, dan fetch type sejak awal menghindarkan kamu dari semua itu.
Konsep inti
@ManyToOne: relasi yang memegang foreign key
Banyak OrderItem menunjuk satu Order. Di tabel, order_items punya kolom order_id. Di JPA, ini @ManyToOne di OrderItem:
java
// Potongan — package id.jalurjava.order
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal unitPrice; // harga DISALIN saat pesan; harga produk boleh berubah nanti
protected OrderItem() {}
OrderItem(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
this.unitPrice = product.getPrice();
}
void setOrder(Order order) { this.order = order; } // package-private: hanya dipanggil Order
public Long getId() { return id; }
public BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}Default fetch @ManyToOne adalah EAGER: setiap kali OrderItem dimuat, Order dan Product-nya ikut dimuat — sering lewat query tambahan. Itu hampir tidak pernah yang kamu mau, jadi biasakan menulis fetch = FetchType.LAZY. Dengan LAZY, Hibernate mengisi field dengan proxy yang baru menjalankan SELECT saat method-nya dipanggil.
@OneToMany(mappedBy) dan sisi pemilik
Relasi bisa dibuat dua arah dengan collection di Order:
java
// Potongan — package id.jalurjava.order
@Entity
@Table(name = "orders") // "order" adalah kata kunci SQL; pakai bentuk jamak
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 30)
private String number;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status = OrderStatus.NEW;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected Order() {}
public Order(String number) { this.number = number; }
public void addItem(Product product, int quantity) {
OrderItem item = new OrderItem(product, quantity);
items.add(item);
item.setOrder(this); // sisi pemilik — inilah yang menentukan isi kolom order_id
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
public String getNumber() { return number; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
}Satu foreign key tidak boleh dikelola dua tempat, jadi JPA membedakan:
- Sisi pemilik (owning side) — sisi yang punya
@JoinColumn, di siniOrderItem.order. Hanya sisi ini yang dibaca Hibernate saat menulisorder_id. - Sisi kebalikan (inverse side) — sisi dengan
mappedBy = "order"(nama field diOrderItem). Ia hanya cermin untuk navigasi; perubahan di sini saja diabaikan saat menyimpan.
Collection diinisialisasi new ArrayList<>() agar tidak pernah null.
Helper method menjaga dua sisi
Karena Hibernate membaca sisi pemilik, sedangkan kode Java-mu (menghitung total, validasi) membaca collection, kedua sisi harus selalu sinkron. Polanya: satu-satunya pintu untuk mengubah relasi adalah helper method di induk, dan getter collection mengembalikan view yang tidak bisa diubah. Logikanya bisa dicoba tanpa database:
java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class BidirectionalDemo {
static class Order {
private final String number;
private final List<OrderItem> items = new ArrayList<>();
Order(String number) { this.number = number; }
// Helper: satu-satunya cara menambah item, dua sisi selalu sinkron
void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
BigDecimal total() {
BigDecimal total = BigDecimal.ZERO;
for (OrderItem item : items) {
total = total.add(item.subtotal());
}
return total;
}
@Override public String toString() { return number; }
}
static class OrderItem {
private Order order; // sisi pemilik: di JPA inilah yang mengisi kolom order_id
private final String sku;
private final int quantity;
private final BigDecimal unitPrice;
OrderItem(String sku, int quantity, BigDecimal unitPrice) {
this.sku = sku;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
void setOrder(Order order) { this.order = order; }
Order getOrder() { return order; }
BigDecimal subtotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); }
}
public static void main(String[] args) {
Order order = new Order("ORD-001");
OrderItem kopi = new OrderItem("KOPI-250", 2, new BigDecimal("85000"));
OrderItem gula = new OrderItem("GULA-500", 1, new BigDecimal("30000"));
order.addItem(kopi);
order.addItem(gula);
System.out.println(order.getItems().size() + " item, total " + order.total());
System.out.println("order milik kopi = " + kopi.getOrder());
order.removeItem(gula);
System.out.println(order.getItems().size() + " item, order milik gula = " + gula.getOrder());
try {
order.getItems().add(gula); // jalan pintas yang melewati helper
} catch (UnsupportedOperationException e) {
System.out.println("Ditolak: " + e.getClass().getSimpleName());
}
}
}Output:
text
2 item, total 200000
order milik kopi = ORD-001
1 item, order milik gula = null
Ditolak: UnsupportedOperationExceptionCascade dan orphanRemoval
Cascade meneruskan operasi EntityManager dari induk ke anak: PERSIST, MERGE, REMOVE, REFRESH, DETACH, atau ALL. Dengan cascade = CascadeType.ALL di Order.items, cukup em.persist(order) dan semua item ikut disimpan; em.remove(order) ikut menghapus item-itemnya.
orphanRemoval = true selangkah lebih jauh: item yang dilepas dari collection (lewat removeItem) dihapus dari database saat flush, tanpa em.remove. Cocok karena OrderItem tidak bermakna tanpa pesanannya.
Aturan pentingnya: cascade hanya untuk relasi induk–anak di mana induk memiliki siklus hidup anak (pesanan → item, keranjang → baris keranjang). Jangan pasang cascade di @ManyToOne seperti OrderItem.product: dengan CascadeType.REMOVE di sana, menghapus satu item ikut menghapus produknya dari katalog.
Fetch type dan LazyInitializationException
| Anotasi | Default | Saran |
|---|---|---|
@ManyToOne, @OneToOne | EAGER | ubah ke LAZY |
@OneToMany, @ManyToMany | LAZY | biarkan LAZY |
Lazy loading butuh persistence context yang masih terbuka. Bila collection lazy disentuh setelah transaksi selesai — entity sudah detached — Hibernate 7 melempar:
text
org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'id.jalurjava.order.Order.items' (no session)Ini biasanya muncul di job terjadwal, test, atau kode yang memproses entity hasil service di luar transaksi.
Open Session in View
Spring Boot secara default mengaktifkan spring.jpa.open-in-view untuk aplikasi web (dan mencatat peringatan saat startup). Persistence context dibiarkan terbuka sampai response selesai, sehingga lazy loading di controller "kebetulan berhasil" — sambil diam-diam menjalankan query di luar transaksi. Banyak tim mematikannya (spring.jpa.open-in-view: false) agar kebutuhan data terlihat jelas di service.
Solusi yang benar bukan mengganti ke EAGER — itu membuat setiap query Order ikut memuat item, bahkan untuk daftar pesanan yang hanya butuh nomor dan status. Muat apa yang dibutuhkan di dalam transaksi (fetch join atau entity graph — bab performa query), lalu petakan ke DTO sebelum keluar dari service.
@ManyToMany dan entity penghubung
Produk bisa masuk banyak kategori, kategori berisi banyak produk:
java
// Potongan — di entity Product
@ManyToMany
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id"))
private Set<Category> categories = new HashSet<>();Join table product_categories hanya berisi dua foreign key. Begitu relasi butuh data sendiri — urutan tampil, kapan ditambahkan, siapa yang menambahkan — @ManyToMany tidak bisa menampungnya. Karena kebutuhan seperti itu hampir selalu datang, banyak tim langsung memakai entity penghubung ProductCategory dengan dua @ManyToOne ditambah kolomnya sendiri. Bila tetap memakai @ManyToMany, gunakan Set (bukan List) dan jangan pasang CascadeType.REMOVE.
Contoh di dunia backend
Checkout membuat pesanan dari keranjang. Produk dimuat dan diperiksa, item dibuat lewat helper, dan satu persist menyimpan semuanya berkat cascade. Hasilnya dipetakan ke DTO di dalam transaksi.
java
// Potongan — package id.jalurjava.order
@Service
public class CheckoutService {
private final EntityManager em;
public CheckoutService(EntityManager em) {
this.em = em;
}
public record CartLine(Long productId, int quantity) {}
public record OrderSummary(String number, int itemCount, BigDecimal total) {}
@Transactional
public OrderSummary checkout(String orderNumber, List<CartLine> cart) {
Order order = new Order(orderNumber);
for (CartLine line : cart) {
Product product = em.find(Product.class, line.productId());
if (product == null || product.getStatus() != ProductStatus.ACTIVE) {
throw new IllegalArgumentException("Produk tidak tersedia: " + line.productId());
}
order.addItem(product, line.quantity()); // kedua sisi relasi terisi
}
em.persist(order); // cascade PERSIST → INSERT orders, lalu INSERT order_items
BigDecimal total = BigDecimal.ZERO;
for (OrderItem item : order.getItems()) {
total = total.add(item.subtotal());
}
return new OrderSummary(order.getNumber(), order.getItems().size(), total);
}
@Transactional
public void removeLine(Long orderId, Long itemId) {
Order order = em.find(Order.class, orderId);
for (OrderItem item : order.getItems()) { // collection lazy dimuat di sini, transaksi masih terbuka
if (item.getId().equals(itemId)) {
order.removeItem(item); // orphanRemoval → DELETE from order_items saat commit
return;
}
}
throw new IllegalArgumentException("Item tidak ditemukan: " + itemId);
}
}Controller menerima OrderSummary, bukan entity Order. Selain mencegah LazyInitializationException, ini menghindari serialisasi JSON berputar tanpa akhir (Order → items → order → ...) dan membocorkan kolom internal. removeLine memakai for-each dengan return segera setelah removeItem, karena collection tidak boleh diubah sambil terus di-iterasi (ingat ConcurrentModificationException di M03). Di production removeLine juga memeriksa order tidak null dan status pesanan masih boleh diubah.
Pertanyaan interview
1. Apa itu sisi pemilik relasi dan kenapa penting?
Sisi pemilik adalah sisi yang memegang @JoinColumn — biasanya @ManyToOne. Hibernate hanya membaca sisi ini saat menulis foreign key; sisi mappedBy diabaikan. Bila kode hanya menambah ke collection tanpa mengisi sisi pemilik, foreign key tersimpan null. Karena itu relasi dua arah diubah lewat helper method yang mengisi kedua sisi.
2. Apa beda CascadeType.REMOVE dan orphanRemoval = true?
CascadeType.REMOVE menghapus anak ketika induknya dihapus dengan em.remove. orphanRemoval juga melakukan itu, ditambah menghapus anak yang hanya dilepas dari collection induk. Keduanya hanya pantas untuk anak yang siklus hidupnya dimiliki induk, seperti OrderItem milik Order.
3. Bagaimana kamu menangani LazyInitializationException?
Pertama pahami penyebabnya: relasi lazy diakses setelah persistence context ditutup. Solusinya memuat data yang dibutuhkan di dalam transaksi — fetch join atau entity graph — lalu memetakan ke DTO sebelum keluar dari service. Mengganti ke EAGER atau mengandalkan Open Session in View hanya menyembunyikan masalah dan biasanya memperburuk performa.
Jebakan umum
- Membiarkan
@ManyToOnedefaultEAGER. Data tak terpakai ikut dimuat; tulisfetch = FetchType.LAZY. - Hanya mengisi satu sisi relasi dua arah. Foreign key
nullatau collection di memori tidak sesuai database. - Cascade di
@ManyToOne(OrderItem.product) — menghapus item bisa ikut menghapus produk. - Mengembalikan entity dari controller. Rawan
LazyInitializationException, rekursi JSON tak berujung, dan bocor data; kembalikan DTO record. - "Memperbaiki" lazy loading dengan
EAGER. Masalah pindah ke performa (dan N+1 — bab performa query). - Nama tabel
orderatauuser. Keduanya kata kunci di PostgreSQL; pakaiorders/users.
Ringkasan
@ManyToOnememegang foreign key (sisi pemilik);@OneToMany(mappedBy = "...")adalah sisi kebalikan.- Ubah relasi dua arah lewat helper method di induk; getter collection mengembalikan view tak bisa diubah.
cascadedanorphanRemovalhanya untuk induk–anak yang siklus hidupnya menyatu, sepertiOrder→OrderItem.- Semua relasi sebaiknya
LAZY;LazyInitializationExceptiondiatasi dengan memuat data di transaksi dan memakai DTO. @ManyToManyhanya untuk relasi tanpa data tambahan; selebihnya pakai entity penghubung.