JPA & Hibernate: Memetakan Java ke Database

14 min readIntermediate
JPAHibernateORMSpring Data

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.

DimensiDunia Java (Objek)Dunia Database (Relasional)
Unit dataObjek / instance kelasBaris pada tabel
IdentitasReferensi objek di memoriPrimary Key
RelasiReferensi langsung antar objek (order.getUser())Foreign Key + JOIN
PerilakuObjek punya methodTabel tidak punya apa-apa
HierarchyInheritance antar kelasTidak 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    │
└───────────────────────────────────────┘
LapisanJenisPeran
JPA (Jakarta Persistence)SpesifikasiMenetapkan "aturan main": anotasi seperti @Entity, API EntityManager, konsep lifecycle. Hanya kontrak, tidak ada kode yang jalan.
HibernateImplementasiMenjalankan aturan itu: membaca anotasi, menulis SQL, mengelola cache dan persistence context.
Spring Data JPAAbstraksiDi 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:

AnotasiFungsi
@EntityMenandai 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.
@IdMenandai primary key. Wajib ada — JPA menolak entity tanpa identitas.
@GeneratedValue(strategy = GenerationType.IDENTITY)Id diisi otomatis database (auto-increment ala BIGSERIAL).
@ColumnMengatur 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:

AnotasiRelasiContoh dunia nyata
@OneToOneSatu-ke-satuSatu UserProfile milik satu User
@ManyToOneBanyak-ke-satu (sisi pemilik FK)Banyak OrderItem milik satu Order
@OneToManySatu-ke-banyak (sisi koleksi)Satu Order berisi banyak OrderItem
@ManyToManyBanyak-ke-banyakUser 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;

    // ...
}
KonsepPenjelasan
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 pemilikJPA 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 @JoinTableArti
name = "user_roles"Nama tabel join yang dibuat di database.
joinColumnsFK ke tabel pemilik (users.id).
inverseJoinColumnsFK 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.

StrategiPerilakuKapan dipakai
EAGERData pasangan langsung dimuat saat entity utama di-load, tanpa menunggu diakses.Relasi kecil yang hampir selalu dibutuhkan bersamaan.
LAZYData 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: EAGER untuk relasi yang Anda yakin selalu dibutuhkan dan berukuran kecil. Untuk koleksi (list of items, tags, dsb.) hampir selalu LAZY — 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.

CascadeTypeDampak
PERSISTMenyimpan entity baru juga menyimpan anaknya (misal simpan OrderOrderItem ikut tersimpan).
REMOVEMenghapus induk juga menghapus anaknya.
ALLGabungan semua operasi (PERSIST + MERGE + REMOVE + REFRESH + DETACH).
MERGE / REFRESH / DETACHOperasi 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:

StatusArtiCiri khas
transientBaru new, belum dikenal JPA.Belum punya id, belum terhubung ke database.
managedTerlampir ke persistence context.Perubahan field otomatis di-flush; masih ada saat transaksi berlangsung.
detachedSudah pernah dikelola, tapi keluar dari persistence context (transaksi selesai).Masih punya id, tapi perubahannya tidak lagi dipantau.
removedDijadwalkan 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 methodBagian yang di-parsingQuery yang dihasilkan
findByEmailEmail → kolom emailSELECT * FROM users WHERE email = ?
findByNameContainingIgnoreCaseName, Containing, IgnoreCaseWHERE UPPER(name) LIKE UPPER('%' + ? + '%')
countByRolecount, RoleSELECT COUNT(*) FROM users WHERE role = ?
deleteByEmaildeleteDELETE 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);
JPQLNative
TargetEntity (nama kelas & field)Tabel dan kolom SQL asli
PortabilitasBerjalan di semua databaseTergantung SQL database tertentu
HasilDikembalikan sebagai entity/objekPerlu 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:

NilaiPerilakuKapan dipakai
createHapus & buat ulang tabel setiap startup.Sekali-sekali untuk prototipe lokal.
updateMenambah kolom/tabel baru yang belum ada.Tidak untuk production (tidak bisa menghapus, bisa salah duga).
validateMemeriksa entity cocok dengan tabel yang ada, error bila tidak.Aman untuk production.
noneTidak 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

  1. Jangan panggil getter berantai di luar transaksi. order.getUser().getEmail() bisa melempar LazyInitializationException ketika 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.
  2. 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.
  3. Letakkan @Transactional di 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.
  4. Selalu LAZY untuk koleksi, dan gabungkan data dengan JOIN FETCH / @EntityGraph secara sadar — jangan serahkan ke EAGER.
  5. 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.
  6. Jangan cascade sembarangan — cascade untuk komposisi (OrderOrderItem), 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

← Back to technical articles