Records: apa yang kamu dapat, dan sampai mana dia menolong
Anggota yang dideklarasikan record untukmu, kenapa compact constructor satu-satunya tempat yang benar untuk invariant, dan tiga situasi di mana record adalah alat yang salah.
Record adalah class yang state-nya adalah API-nya. Satu baris mendeklarasikan komponennya, compiler menulis sisanya.
public record Money(long amount, String currency) {}Jalankan, dan anggota yang dihasilkan itu bukan klaim lagi — dia keluaran:
record Money(long amount, String currency) {}
public class Demo { public static void main(String[] args) { Money m = new Money(1200, "IDR"); System.out.println(m); // toString System.out.println(m.amount() + " " + m.currency()); // accessor System.out.println(m.equals(new Money(1200, "IDR"))); // equals }}Itu memberimu, secara implisit: konstruktor kanonik yang menerima semua komponen
sesuai urutan deklarasi, satu accessor per komponen dengan nama yang sama
(amount(), bukan getAmount()), serta equals, hashCode, dan toString
yang diturunkan dari semua komponen. Yang tidak kamu dapat: setter,
konstruktor tanpa argumen, dan kemampuan meng-extend apa pun — record itu
implicitly final.
Compact constructor adalah tempat invariant
Kamu bisa memvalidasi di dalam record, dan ada tepat satu tempat yang benar.
public record Money(long amount, String currency) { public Money { if (amount < 0) { throw new IllegalArgumentException("jumlah negatif: " + amount); } if (currency == null || currency.length() != 3) { throw new IllegalArgumentException("harusnya ISO 4217, dapat: " + currency); } currency = currency.toUpperCase(); // normalisasi boleh di sini }}Kedua sisinya terlihat kalau dijalankan — nilai yang dinormalkan, dan objek yang tidak pernah jadi ada:
record Money(long amount, String currency) { Money { if (amount < 0) throw new IllegalArgumentException("negative amount: " + amount); currency = currency.toUpperCase(); }}
public class Demo { public static void main(String[] args) { System.out.println(new Money(500, "idr")); // dinormalkan saat masuk try { new Money(-1, "IDR"); } catch (IllegalArgumentException e) { System.out.println("ditolak: " + e.getMessage()); } }}Itu compact constructor — tanpa daftar parameter, tanpa assignment. Dia jalan sebelum field di-assign, dan justru itu gunanya: menugaskan ke nama parameter menormalkan nilai yang akhirnya tersimpan, dan melempar exception berarti objeknya tidak pernah jadi ada.
Alternatif yang biasa dipakai orang adalah static factory yang memvalidasi lalu memanggil konstruktor. Itu jalan sampai ada yang memanggil konstruktornya langsung — dan itu akan terjadi, karena konstruktornya publik dan ada di situ. Validasi di compact constructor tidak bisa dilewati.
Kesetaraan berasal dari komponennya, dan itu bergigi
equals dan hashCode dibangkitkan dari setiap komponen. Biasanya itu yang
kamu mau, kadang jadi jebakan:
record CacheKey(String tenant, List<String> scopes) {}Dua CacheKey dengan list yang setara itu sama — bagus. Tapi kalau list-nya
diubah setelah key itu masuk HashMap, hashCode-nya berubah dan entry-nya
jadi tidak terjangkau: dia masih di dalam map, di bucket yang tidak ditunjuk
hash barunya. containsKey menjawab tidak ada, iterasi masih menampilkannya.
Perbaikannya di saat konstruksi, bukan di pemanggil:
record CacheKey(String tenant, List<String> scopes) { CacheKey { scopes = List.copyOf(scopes); // salinan defensif, dan immutable setelahnya }}List.copyOf mengembalikan list yang tidak bisa diubah dan menyalin, jadi
pemanggil yang masih memegang referensi aslinya tidak bisa menjangkau ke dalam.
Perlu diingat List.of(...) dan List.copyOf(...) menolak null dan melempar
UnsupportedOperationException untuk setiap perubahan — itu disengaja, bukan
kelupaan.
Tiga tempat di mana record adalah jawaban yang salah
Entity JPA. JPA butuh konstruktor tanpa argumen untuk membuat instance dari satu baris data, dan field mutable untuk diisi. Record tidak punya keduanya, dan karena final dia tidak bisa di-proxy untuk lazy loading. Pola yang jalan: record di luar — request body, response, value domain — dan class biasa di boundary persistence. Memaksa record jadi entity adalah pertarungan yang kamu kalah perlahan.
Copy-with-changes dalam jumlah banyak. Bahasanya tidak punya wither, jadi
kamu tulis sendiri:
record Money(long amount, String currency) { Money withAmount(long newAmount) { return new Money(newAmount, currency); }}Wajar untuk dua-tiga komponen. Di dua belas, kamu butuh builder — dan record dengan dua belas komponen sedang memberi tahu bahwa dia sebenarnya dua record.
Apa pun yang punya identitas, bukan nilai. Record berkata “komponen-komponen
ini, bersama-sama, adalah benda itu”. User yang tetap user yang sama setelah
emailnya berubah punya identitas, bukan kesetaraan nilai. Record memodelkan nilai.
Pembahasan lebih panjangnya, termasuk pola wither dan caveat JPA secara detail: Java Records di Lapangan.
Diskusi
Memuat komentar…