Chapter sebelumnya memetakan kosakata Domain-Driven Design sampai ke blok bangunan taktisnya. Sekarang kita turunkan kosakata tersebut menjadi model Java yang nyata: kapan sebuah konsep cukup diperlakukan sebagai value object, kapan konsep tersebut membutuhkan identitas sebagai entity, dan bagaimana aggregate root menjaga aturan bisnis agar tidak bisa dilanggar dari luar. Tiga keputusan bentuk inilah yang menentukan apakah kode domain kita rapuh atau tahan lama. Sepanjang chapter kita memakai satu domain yang akrab — belanja — supaya setiap keputusan terasa konkret dan bisa dijalankan sendiri.
Value Object yang Dibandingkan berdasarkan Nilai #
Value object adalah konsep domain yang identitasnya tidak penting: yang penting nilainya. Dua kupon senilai Rp50.000 dapat dipertukarkan tanpa ada yang dirugikan, sama seperti dua angka 7 tidak perlu dibedakan. Akibatnya, value object dirancang dengan tiga sifat: tidak dapat diubah (immutable), dibandingkan berdasarkan nilai (equals-by-value), dan memvalidasi dirinya sendiri sejak penciptaan.
Java modern menyediakan alat yang persis cocok untuk ketiganya: record. Compiler javac menghasilkan method equals, hashCode, dan toString yang membandingkan seluruh field berdasarkan nilainya, bukan berdasarkan alamat objek di memori. Compact constructor — blok tanpa parameter di dalam tubuh record — dieksekusi sebelum field diisi, sehingga nilai tidak sah ditolak sebelum objek sempat ada. Dibandingkan class biasa yang menuntut kita menulis equals dan hashCode manual (rawan terlewat), record menutup celah tersebut sejak baris pertama.
Entity yang Disamakan Lewat Identitas #
Entity adalah kebalikan value object: objek yang identitasnya menetap sepanjang waktu walau atributnya berubah. Keranjang belanja milik Budi tetap keranjang yang sama setelah isi, total, bahkan nama toko di dalamnya berubah — identitasnya adalah KR-001, bukan isi fieldnya. Entity boleh memiliki keadaan yang berubah, tetapi perubahannya harus melewati method yang dituju, bukan lewat field publik yang bebas ditulis siapa saja.
Konsekuensi teknisnya ada dua. Pertama, equals entity membandingkan identitas — dua keranjang ber-id sama dianggap sama sekalipun isinya berbeda. Kedua, identitas harus dipilih dari domain (nomor keranjang, nomor resi), bukan dari hashCode() objek bebas yang berubah setiap proses berjalan.
Aggregate Root Menjaga Invarian #
Aggregate adalah kelompok entity dan value object yang berubah bersama sebagai satu kesatuan, dengan satu anggota ditunjuk sebagai aggregate root — satu-satunya pintu masuk dari dunia luar. Invarian adalah aturan yang harus selalu benar bagi keseluruhan kelompok; pada keranjang belanja, contohnya: satu produk hanya menempati satu baris, jumlah item minimal satu, dan total selalu jumlah seluruh subtotal.
Bila kode luar boleh memegang Item secara langsung dan mengubah jumlahnya, siapa pun bisa menulis 0 atau nilai negatif, dan invarian rusak diam-diam. Dengan root sebagai satu-satunya pintu, setiap perubahan melewati validasi di satu tempat yang dapat dipercaya.
Domain Belanja dalam Kode #
Program berikut merangkai ketiga blok tersebut: Item sebagai value object berbentuk record, Keranjang sebagai entity sekaligus aggregate root, dan aturan bisnis yang ditegakkan di dalam root:
import java.util.ArrayList;
import java.util.List;
public class KeranjangBelanja {
public static void main(String[] args) {
// Value object dibandingkan berdasarkan nilai, bukan lokasi objek di memori
Item bukuA = new Item("Buku Java", 2, 85_000);
Item bukuB = new Item("Buku Java", 2, 85_000);
System.out.println("dua item bernilai sama dianggap sama: " + bukuA.equals(bukuB));
// Keranjang adalah aggregate: satu pintu untuk mengubah seluruh isinya
Keranjang keranjang = new Keranjang("KR-001");
keranjang.tambah(bukuA);
keranjang.tambah(new Item("Pena", 3, 12_000));
keranjang.tambah(new Item("Buku Java", 1, 85_000)); // digabung, bukan item baru
System.out.println(keranjang.ringkas());
// Invarian dijaga aggregate root, bukan kepercayaan pada pemanggil
try {
keranjang.ubahJumlah("Pena", 0);
} catch (IllegalArgumentException e) {
System.out.println("ditolak aggregate: " + e.getMessage());
}
// Entity disamakan lewat identitas, sekalipun isi keranjangnya berbeda
Keranjang keranjangLain = new Keranjang("KR-001");
System.out.println("keranjang lain ber-id sama dianggap sama: "
+ keranjang.equals(keranjangLain));
}
}
// Value object: tanpa identitas, tidak dapat diubah, dibandingkan berdasarkan nilai
record Item(String nama, int jumlah, long hargaSatuan) {
Item {
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama produk wajib diisi");
}
if (jumlah < 1) {
throw new IllegalArgumentException("jumlah minimal 1, diterima: " + jumlah);
}
if (hargaSatuan < 0) {
throw new IllegalArgumentException("harga tidak boleh negatif");
}
}
long subtotal() {
return (long) jumlah * hargaSatuan;
}
}
// Aggregate root: pemilik invarian dan satu-satunya pintu menuju item di dalamnya
class Keranjang {
private final String id;
private final List<Item> items = new ArrayList<>();
Keranjang(String id) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id keranjang wajib diisi");
}
this.id = id;
}
void tambah(Item itemBaru) {
for (int i = 0; i < items.size(); i++) {
Item ada = items.get(i);
if (ada.nama().equals(itemBaru.nama())) {
items.set(i, new Item(ada.nama(),
ada.jumlah() + itemBaru.jumlah(), ada.hargaSatuan()));
return;
}
}
items.add(itemBaru);
}
void ubahJumlah(String nama, int jumlahBaru) {
if (jumlahBaru < 1) {
throw new IllegalArgumentException("jumlah item \"" + nama
+ "\" minimal 1, diterima: " + jumlahBaru);
}
for (int i = 0; i < items.size(); i++) {
if (items.get(i).nama().equals(nama)) {
Item lama = items.get(i);
items.set(i, new Item(lama.nama(), jumlahBaru, lama.hargaSatuan()));
return;
}
}
throw new IllegalArgumentException("item \"" + nama + "\" tidak ada di keranjang");
}
long total() {
long total = 0;
for (Item item : items) {
total += item.subtotal();
}
return total;
}
String ringkas() {
StringBuilder teks = new StringBuilder("Keranjang " + id + ":");
for (Item item : items) {
teks.append("\n - ").append(item.nama())
.append(" x").append(item.jumlah())
.append(" = Rp").append(item.subtotal());
}
teks.append("\n total = Rp").append(total());
return teks.toString();
}
@Override
public boolean equals(Object obj) {
return obj instanceof Keranjang lain && lain.id.equals(id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}Output yang Dihasilkan:
dua item bernilai sama dianggap sama: true
Keranjang KR-001:
- Buku Java x3 = Rp255000
- Pena x3 = Rp36000
total = Rp291000
ditolak aggregate: jumlah item "Pena" minimal 1, diterima: 0
keranjang lain ber-id sama dianggap sama: trueMari kita bedah blok demi blok model di atas:
- Baris 33-40 (
record Item): compact constructor menolak nama kosong, jumlah di bawah satu, dan harga negatif sebelum field terisi.equalsyang dihasilkan compiler membandingkan ketiga field berdasarkan nilai — terbukti pada baris 7-9 bahwabukuAdanbukuBdianggap sama walau dua objek berbeda di memori. - Baris 63-73 (
tambah): invarian “satu baris per produk” ditegakkan dengan menggabungkan item senama. KarenaItemimmutable, penggabungan membuat objekItembaru lewatitems.set(...), bukan mengubah field objek lama yang mungkin masih dipegang pihak lain. - Baris 75-88 (
ubahJumlah): jumlah baru divalidasi lebih dulu, lalu item lama diganti item baru. Pemanggil tidak punya jalan lain untuk menulis jumlah0— itulah makna “invarian dijaga root”, dan baris 19-23 menunjukkan penolakan tersebut sebagaiIllegalArgumentExceptionyang tertangkap. - Baris 110-117 (
equalsdanhashCode): entity membandingkan hanyaid.keranjangLainberisi nol item tetapi ber-id sama, dan hasil perbandingannya tetaptrue— perilaku yang tepat untuk entity, namun keliru bila diterapkan pada value object. - Baris 55-61 (
constructor Keranjang): identitas divalidasi sejak penciptaan; fielditemsdirancangprivatetanpa pengambil publik, sehingga satu-satunya cara mengubah isi memang melewati method root.
Model domain yang baik adalah model yang tidak bisa dibuat berbohong: nilai tidak sah tertolak sejak penciptaan, perubahan melewati satu pintu, dan identitas jelas milik siapa. Namun pola penataan seperti ini sudah ditemukan dan dinamai puluhan tahun lalu — dan mengenal namanya membuat kita bisa berdiskusi dengan tim tanpa mendeskripsikan struktur kode dari nol. Chapter berikutnya memperkenalkan katalog besar itu: design patterns.
Referensi #
- Martin Fowler: Patterns of Enterprise Application Architecture — Catalog
- DDD Community
- Oracle: Java Records (dev.java)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
KeranjangBelanja.java
Unduh berkasimport java.util.ArrayList;
import java.util.List;
public class KeranjangBelanja {
public static void main(String[] args) {
// Value object dibandingkan berdasarkan nilai, bukan lokasi objek di memori
Item bukuA = new Item("Buku Java", 2, 85_000);
Item bukuB = new Item("Buku Java", 2, 85_000);
System.out.println("dua item bernilai sama dianggap sama: " + bukuA.equals(bukuB));
// Keranjang adalah aggregate: satu pintu untuk mengubah seluruh isinya
Keranjang keranjang = new Keranjang("KR-001");
keranjang.tambah(bukuA);
keranjang.tambah(new Item("Pena", 3, 12_000));
keranjang.tambah(new Item("Buku Java", 1, 85_000)); // digabung, bukan item baru
System.out.println(keranjang.ringkas());
// Invarian dijaga aggregate root, bukan kepercayaan pada pemanggil
try {
keranjang.ubahJumlah("Pena", 0);
} catch (IllegalArgumentException e) {
System.out.println("ditolak aggregate: " + e.getMessage());
}
// Entity disamakan lewat identitas, sekalipun isi keranjangnya berbeda
Keranjang keranjangLain = new Keranjang("KR-001");
System.out.println("keranjang lain ber-id sama dianggap sama: "
+ keranjang.equals(keranjangLain));
}
}
// Value object: tanpa identitas, tidak dapat diubah, dibandingkan berdasarkan nilai
record Item(String nama, int jumlah, long hargaSatuan) {
Item {
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama produk wajib diisi");
}
if (jumlah < 1) {
throw new IllegalArgumentException("jumlah minimal 1, diterima: " + jumlah);
}
if (hargaSatuan < 0) {
throw new IllegalArgumentException("harga tidak boleh negatif");
}
}
long subtotal() {
return (long) jumlah * hargaSatuan;
}
}
// Aggregate root: pemilik invarian dan satu-satunya pintu menuju item di dalamnya
class Keranjang {
private final String id;
private final List<Item> items = new ArrayList<>();
Keranjang(String id) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("id keranjang wajib diisi");
}
this.id = id;
}
void tambah(Item itemBaru) {
for (int i = 0; i < items.size(); i++) {
Item ada = items.get(i);
if (ada.nama().equals(itemBaru.nama())) {
items.set(i, new Item(ada.nama(),
ada.jumlah() + itemBaru.jumlah(), ada.hargaSatuan()));
return;
}
}
items.add(itemBaru);
}
void ubahJumlah(String nama, int jumlahBaru) {
if (jumlahBaru < 1) {
throw new IllegalArgumentException("jumlah item \"" + nama
+ "\" minimal 1, diterima: " + jumlahBaru);
}
for (int i = 0; i < items.size(); i++) {
if (items.get(i).nama().equals(nama)) {
Item lama = items.get(i);
items.set(i, new Item(lama.nama(), jumlahBaru, lama.hargaSatuan()));
return;
}
}
throw new IllegalArgumentException("item \"" + nama + "\" tidak ada di keranjang");
}
long total() {
long total = 0;
for (Item item : items) {
total += item.subtotal();
}
return total;
}
String ringkas() {
StringBuilder teks = new StringBuilder("Keranjang " + id + ":");
for (Item item : items) {
teks.append("\n - ").append(item.nama())
.append(" x").append(item.jumlah())
.append(" = Rp").append(item.subtotal());
}
teks.append("\n total = Rp").append(total());
return teks.toString();
}
@Override
public boolean equals(Object obj) {
return obj instanceof Keranjang lain && lain.id.equals(id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}