Tampilan
Kenapa penting
Di bab sebelumnya kamu sudah memetakan entity dan relasinya. Sekarang pertanyaannya: bagaimana mengambil dan menyimpan data itu tanpa menulis EntityManager, JPQL, dan kode boilerplate yang sama berulang kali? Di hampir semua project Spring Boot, jawabannya adalah Spring Data JPA repository: kamu cukup mendeklarasikan interface, dan Spring membuatkan implementasinya saat aplikasi start. Paham cara kerjanya — kapan nama method cukup, kapan perlu @Query, dan kapan perlu projection — menentukan apakah aplikasi kamu rapi dan cepat, atau penuh query lambat yang memuat data jauh lebih banyak dari yang dibutuhkan.
Konsep inti
JpaRepository<T, ID>: repository generic
Ingat generic class dan generic interface dari M04: satu definisi dipakai untuk banyak tipe. Spring Data memakai pola itu persis. JpaRepository<T, ID> punya dua parameter tipe: T adalah entity, ID adalah tipe primary key-nya.
java
// Potongan — entity dari bab sebelumnya (disederhanakan)
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private String customerEmail;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
private Instant createdAt;
// getter, setter, constructor protected untuk JPA
}java
// Potongan
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
}Kenapa bukan Order?
ORDER adalah kata kunci di SQL dan JPQL (ORDER BY). Entity bernama Order sering memaksa kamu memberi kutip pada nama tabel atau memicu error parsing. Nama seperti PurchaseOrder dengan tabel purchase_orders menghindari masalah itu.
Interface kosong itu sudah memberi kamu puluhan method. JpaRepository mewarisi ListCrudRepository (CRUD yang mengembalikan List), ListPagingAndSortingRepository (paginasi & sorting, dibahas di bab paginasi), dan menambah method khusus JPA seperti flush() dan saveAndFlush().
| Method | Fungsi |
|---|---|
save(entity) | insert bila entity baru, merge bila sudah ada; kembalikan entity hasil simpan |
saveAll(iterable) | save untuk banyak entity |
findById(id) | Optional<T> — kosong bila tidak ada |
findAll() / findAllById(ids) | List<T> |
existsById(id) / count() | cek keberadaan / jumlah baris |
deleteById(id) / delete(entity) | hapus |
Spring Boot otomatis memindai interface yang mewarisi Repository di package aplikasi (dan sub-package-nya) lalu mendaftarkan implementasinya sebagai bean. Kamu cukup meng-inject-nya lewat constructor, seperti bean lain di M07.
findById dan Optional
findById tidak pernah mengembalikan null; hasilnya Optional<T>. Pola yang sama berlaku untuk repository apa pun. Contoh berikut meniru kontrak repository dengan Map di memori agar bisa dijalankan tanpa database:
java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
public class RepositoryDemo {
interface CrudRepository<T, ID> {
T save(T entity);
Optional<T> findById(ID id);
List<T> findAll();
boolean existsById(ID id);
long count();
}
record Product(Long id, String sku, long priceInRupiah) {}
static class InMemoryProductRepository implements CrudRepository<Product, Long> {
private final Map<Long, Product> rows = new HashMap<>();
public Product save(Product p) { rows.put(p.id(), p); return p; }
public Optional<Product> findById(Long id) { return Optional.ofNullable(rows.get(id)); }
public List<Product> findAll() { return new ArrayList<>(rows.values()); }
public boolean existsById(Long id) { return rows.containsKey(id); }
public long count() { return rows.size(); }
}
public static void main(String[] args) {
CrudRepository<Product, Long> repo = new InMemoryProductRepository();
repo.save(new Product(1L, "KOPI-250G", 45_000));
Product found = repo.findById(1L).orElseThrow();
System.out.println("Ketemu: " + found.sku());
String label = repo.findById(99L)
.map(Product::sku)
.orElse("(tidak ada)");
System.out.println("ID 99: " + label);
System.out.println("exists 1? " + repo.existsById(1L) + ", count = " + repo.count());
try {
repo.findById(99L).orElseThrow();
} catch (NoSuchElementException e) {
System.out.println("Error: " + e.getMessage());
}
}
}text
Ketemu: KOPI-250G
ID 99: (tidak ada)
exists 1? true, count = 1
Error: No value presentPesan No value present tidak berguna bagi klien API. Di service, ubah Optional kosong menjadi exception domain yang nanti dipetakan ke 404 Not Found oleh @RestControllerAdvice (M07):
java
// Potongan
PurchaseOrder order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));Derived query method
Untuk query sederhana, kamu tidak perlu menulis JPQL. Spring Data mengurai nama method dan membangun query-nya: awalan (findBy, existsBy, countBy, deleteBy), lalu nama properti entity yang digabung dengan And/Or, plus kata kunci kondisi seperti After, Between, In, Containing, IgnoreCase, atau OrderBy...Desc.
java
// Potongan
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
Optional<PurchaseOrder> findByOrderNumber(String orderNumber);
// WHERE status = ? AND created_at > ?
List<PurchaseOrder> findByStatusAndCreatedAtAfter(OrderStatus status, Instant after);
// WHERE customer_email = ? ORDER BY created_at DESC
List<PurchaseOrder> findByCustomerEmailOrderByCreatedAtDesc(String customerEmail);
List<PurchaseOrder> findTop5ByStatusOrderByTotalAmountDesc(OrderStatus status);
boolean existsByOrderNumber(String orderNumber);
long countByStatus(OrderStatus status);
}Nama method dicocokkan dengan nama field entity, bukan nama kolom tabel. Kalau ada properti yang tidak dikenal, Spring Data menolak membuat repository dan aplikasi gagal start — kesalahan ketik ketahuan lebih awal, bukan di production. Untuk kebutuhan "ada atau tidak", pakai existsBy...: query-nya berhenti di baris pertama dan tidak memuat entity. Begitu juga countBy... untuk menghitung tanpa memuat data.
@Query: JPQL dan native
Nama method yang terlalu panjang (findByStatusAndCreatedAtAfterAndCustomerEmailContainingIgnoreCase...) adalah sinyal untuk menulis query sendiri dengan @Query. Defaultnya JPQL: bahasa query berbasis entity dan field, bukan tabel dan kolom.
java
// Potongan
@Query("""
select o from PurchaseOrder o
where o.status = :status
and o.totalAmount >= :minTotal
order by o.createdAt desc
""")
List<PurchaseOrder> findLargeOrders(@Param("status") OrderStatus status,
@Param("minTotal") BigDecimal minTotal);:status dan :minTotal adalah parameter bernama; @Param menghubungkannya ke argumen method. Spring Data juga bisa membaca nama parameter langsung bila kode dikompilasi dengan flag -parameters (parent POM Spring Boot menyalakannya), tetapi @Param membuat maksudnya eksplisit dan tetap aman bila setelan build berubah. Jangan pernah menyusun query dengan menyambung String dari input pengguna — parameter mencegah SQL injection, sama seperti PreparedStatement di M06.
Bila butuh fitur khusus PostgreSQL (fungsi tanggal, jsonb, CTE), pakai native query dengan @NativeQuery (singkatan dari @Query(nativeQuery = true)). Di sini kamu menulis SQL biasa dengan nama tabel dan kolom:
java
// Potongan
@NativeQuery("""
select cast(created_at as date) as day, count(*) as total
from purchase_orders
where created_at >= :since
group by 1
order by 1
""")
List<DailyOrderCount> countPerDay(@Param("since") Instant since);Harga native query: terikat ke satu database, tidak dicek saat start-up, dan rawan rusak saat nama kolom berubah. Pakai seperlunya.
Projection: ambil hanya yang dibutuhkan
Memuat entity lengkap untuk halaman daftar yang hanya menampilkan tiga kolom itu boros. Projection membuat query hanya mengambil kolom tertentu. Ada dua gaya:
java
// Potongan — interface projection: getter yang namanya cocok dengan properti/alias
public interface DailyOrderCount {
LocalDate getDay();
long getTotal();
}
// Potongan — DTO projection dengan record
public record OrderSummary(String orderNumber, OrderStatus status, BigDecimal totalAmount) {}java
// Potongan
List<OrderSummary> findByCustomerEmail(String customerEmail); // derived query + DTO
@Query("""
select new com.example.shop.order.OrderSummary(o.orderNumber, o.status, o.totalAmount)
from PurchaseOrder o
where o.status = :status
""")
List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);Constructor expression select new ... memakai nama class lengkap (dengan package). Projection cocok dikembalikan langsung sebagai response API: tidak ada lazy loading tersembunyi dan tidak ada field internal yang ikut terserialisasi. Interface projection paling praktis untuk native query (alias kolom → getter); DTO record paling enak untuk JPQL dan derived query.
@Modifying: update dan delete massal
Mengubah satu entity cukup dengan memuat, mengubah field, lalu membiarkan transaksi menyimpannya (dirty checking dari bab entity). Tetapi untuk mengubah ribuan baris sekaligus, memuat semuanya satu per satu itu lambat. Pakai query modifikasi:
java
// Potongan
@Modifying(clearAutomatically = true)
@Query("""
update PurchaseOrder o set o.status = :expired
where o.status = :pending and o.createdAt < :before
""")
int expirePendingOrders(@Param("pending") OrderStatus pending,
@Param("expired") OrderStatus expired,
@Param("before") Instant before);Nilai kembaliannya jumlah baris yang berubah. Ingat dua hal: query ini melewati persistence context (entity yang sudah dimuat tidak ikut berubah — clearAutomatically = true mengosongkannya setelah query), dan ia harus berjalan di dalam transaksi tulis. Transaksi dibahas tuntas di bab berikutnya.
Kapan menulis query sendiri?
| Kebutuhan | Pilihan |
|---|---|
| Cari berdasarkan 1–2 properti, urutan sederhana | derived query method |
| Cek ada/tidak, hitung | existsBy..., countBy... |
| Kondisi kompleks, join, nama method jadi tidak terbaca | @Query JPQL |
| Fitur khusus PostgreSQL, laporan agregat berat | @NativeQuery |
| Daftar/laporan yang hanya butuh sebagian kolom | projection (interface/DTO) |
| Update/delete massal | @Modifying + @Query |
Contoh di dunia backend
Service pesanan berikut memakai repository untuk validasi, pencarian, dan laporan. Perhatikan bahwa controller tidak pernah menyentuh repository langsung.
java
// Potongan
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
Optional<PurchaseOrder> findByOrderNumber(String orderNumber);
boolean existsByOrderNumber(String orderNumber);
List<OrderSummary> findByCustomerEmailOrderByCreatedAtDesc(String customerEmail);
long countByStatusAndCreatedAtAfter(OrderStatus status, Instant after);
@Modifying(clearAutomatically = true)
@Query("""
update PurchaseOrder o set o.status = :expired
where o.status = :pending and o.createdAt < :before
""")
int expirePendingOrders(@Param("pending") OrderStatus pending,
@Param("expired") OrderStatus expired,
@Param("before") Instant before);
}java
// Potongan
@Service
public class OrderQueryService {
private final PurchaseOrderRepository orders;
private final Clock clock;
public OrderQueryService(PurchaseOrderRepository orders, Clock clock) {
this.orders = orders;
this.clock = clock;
}
public PurchaseOrder getByNumber(String orderNumber) {
return orders.findByOrderNumber(orderNumber)
.orElseThrow(() -> new OrderNotFoundException(orderNumber));
}
public List<OrderSummary> historyOf(String customerEmail) {
return orders.findByCustomerEmailOrderByCreatedAtDesc(customerEmail);
}
public long paidOrdersToday() {
Instant startOfDay = LocalDate.now(clock).atStartOfDay(clock.getZone()).toInstant();
return orders.countByStatusAndCreatedAtAfter(OrderStatus.PAID, startOfDay);
}
@Transactional
public int expireStaleOrders() {
Instant cutoff = Instant.now(clock).minus(Duration.ofHours(24));
return orders.expirePendingOrders(OrderStatus.PENDING, OrderStatus.EXPIRED, cutoff);
}
}Clock di-inject agar test bisa mengatur "sekarang" (M06). Riwayat pesanan dikembalikan sebagai OrderSummary sehingga query hanya mengambil tiga kolom, dan expireStaleOrders berjalan dalam transaksi karena memakai @Modifying.
Pertanyaan interview
1. Bagaimana Spring Data bisa menjalankan method repository padahal kamu hanya menulis interface?
Saat aplikasi start, Spring Data memindai interface yang mewarisi Repository dan membuat implementasinya sebagai proxy. Method CRUD diteruskan ke implementasi bawaan (SimpleJpaRepository), sedangkan derived query method diurai dari namanya menjadi query JPA, dan method ber-@Query memakai query yang kamu tulis. Semua ini divalidasi saat start-up, sehingga nama properti yang salah membuat aplikasi gagal start.
2. Apa beda JPQL dan native query, dan kapan memakai masing-masing?
JPQL ditulis terhadap entity dan field, portabel antardatabase, dan divalidasi Hibernate. Native query adalah SQL mentah terhadap tabel dan kolom — dipakai bila butuh fitur khusus database (fungsi PostgreSQL, CTE, jsonb) atau query laporan yang sulit diekspresikan di JPQL. Default-nya JPQL; native hanya bila ada alasan jelas.
3. Kenapa projection lebih baik daripada mengembalikan entity untuk endpoint daftar?
Projection hanya mengambil kolom yang dibutuhkan, tidak memasukkan objek ke persistence context, dan tidak memicu lazy loading relasi saat diserialisasi. Hasilnya query lebih ringan, memori lebih hemat, dan kontrak API tidak bocor mengikuti struktur entity.
Jebakan umum
- Memanggil
findById(id).get(). Bila kosong, kamu mendapatNoSuchElementException: No value presentyang berakhir sebagai500. PakaiorElseThrow(() -> new XxxNotFoundException(id)). - Nama derived query sepanjang kalimat. Bila nama method sudah lebih dari tiga kondisi, pindah ke
@Querydengan nama method yang bermakna bisnis. - Lupa
@Modifyingatau transaksi pada queryupdate/delete— query gagal saat dijalankan. - Mengira query
@Modifyingikut memperbarui entity yang sudah dimuat. Query massal langsung ke database; pakaiclearAutomatically = trueatau muat ulang entity. - Menyambung string input pengguna ke dalam
@Query. Selalu pakai parameter bernama. - Memuat entity penuh hanya untuk cek keberadaan (
findByEmail(...).isPresent()). PakaiexistsByEmail.
Ringkasan
JpaRepository<T, ID>memberi CRUD, paginasi, dan sorting bawaan; Spring Boot membuat implementasinya otomatis.findByIdmengembalikanOptional<T>; ubah hasil kosong menjadi exception domain yang dipetakan ke404.- Derived query method membangun query dari nama method;
existsBydancountByefisien untuk cek dan hitung. @Queryuntuk JPQL dengan parameter bernama,@NativeQueryuntuk SQL khusus database.- Projection (interface atau DTO
record) hanya mengambil kolom yang dibutuhkan — ideal untuk daftar dan laporan. @Modifyinguntuk update/delete massal; wajib dalam transaksi dan tidak memperbarui entity yang sudah dimuat.