Clean architecture menjawab ke arah mana dependensi mengalir; Domain-Driven Design (DDD) menjawab pertanyaan yang lebih mendasar: tentang apa sebenarnya aplikasi ini berbicara. Eric Evans mengajukan DDD pada buku tahun 2003 dengan premis sederhana namun dalam: model perangkat lunak harus meniru cara ahli bisnis berpikir, dan bahasa yang dipakai tim — programmer, analis, pemilik produk — harus menjadi satu. Ketika pemilik produk mengatakan “resi sudah dibuat” dan kode memang memanggil buatPengiriman(), percakapan dan program berjalan seirama. Chapter ini membahas empat gagasan strategis DDD — ubiquitous language, bounded context, blok bangunan taktis, dan anti-corruption layer — dengan contoh domain ekspedisi.
Ubiquitous Language #
Ubiquitous language (bahasa yang hadir di mana-mana) adalah kosakata resmi proyek: sama di rapat, dokumen, nama class, nama method, pengujian, hingga label antarmuka. Bila ahli ekspedisi mengatakan resi, kode pun menyebut Pengiriman dengan nomorResi — bukan OrderDTO atau record567. Pemetaan untuk domain ekspedisi kita:
| Istilah ahli bisnis | Ungkapan dalam kode | Blok DDD |
|---|---|---|
| “resi” | Pengiriman dengan nomorResi | Entity |
| “berat kiriman” | Berat (gram) | Value object |
| “ongkir” | Ongkir (rupiah) | Value object |
| “buat resi, hitung ongkir” | LayananEkspedisi.buatPengiriman | Domain service |
| “lacak paket” | lacak(nomorResi) | Method entity/service |
Bahasa yang pecah adalah sinyal bahaya: bila dalam rapat tim menyebut “kirim data” sedangkan kode menyebut insertRow, berarti model belum menangkap domain, dan setiap diskusi harus diterjemahkan dua kali — sumber salah paham yang mahal.
Bounded Context #
Kata yang sama bisa berarti berbeda antar bagian organisasi. “Produk” di tim Katalog berarti judul dan harga; di tim Gudang berarti SKU, lokasi rak, dan stok; di tim Ekspedisi tidak relevan — yang ada “kiriman”. DDD menuntaskan benturan ini dengan bounded context: setiap konteks memiliki model sendiri dan batas yang tegas, alih-alih satu model raksasa yang memaksakan kompromi semua pihak.
Penerjemah di batas antarkonteks inilah anti-corruption layer (ACL): lapisan pelindung yang menerjemahkan model sistem lain ke model konteks kita, sehingga kekacauan sistem luar tidak bocor masuk. Ketika sistem kurir lawas mengirim field RCV_NM dan WT_G, ACL mengubahnya menjadi Pengiriman dan Berat — kode domain tidak pernah tahu bentuk aslinya.
Blok Bangunan Taktis #
Dalam satu bounded context, model dirakit dari blok-blok taktis:
| Blok | Sifat | Contoh |
|---|---|---|
| Value object | Tanpa identitas; dibandingkan berdasarkan nilai; tidak dapat diubah | Berat, Ongkir |
| Entity | Beridentitas; dua objek dengan ID sama dianggap sama | Pengiriman |
| Aggregate | Kumpulan entity dengan satu aggregate root penjaga invarian | KeranjangBelanja |
| Domain service | Operasi domain yang tidak wajar dimiliki satu entity | hitungOngkir lintas tarif |
| Repository | Koleksi aggregate seolah ada di memori | LayananEkspedisi.resi |
Pembahasan mendalam tiap blok menjadi isi chapter berikutnya; program di bawah ini cukup menunjukkan tiga yang paling sering dipakai.
Domain Ekspedisi dalam Kode #
Program berikut menegakkan ubiquitous language pada tiga blok: value object yang memvalidasi diri, entity beridentitas, dan domain service pemilik aturan ongkir:
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public class BahasaDomain {
public static void main(String[] args) {
LayananEkspedisi ekspedisi = new LayananEkspedisi();
Pengiriman pertama = ekspedisi.buatPengiriman("Budi", "Siti", 1_500);
Pengiriman kedua = ekspedisi.buatPengiriman("Dewi", "Budi", 500);
System.out.println(pertama.ringkas());
System.out.println(kedua.ringkas());
System.out.println("Lacak EXP-001: "
+ ekspedisi.lacak("EXP-001").map(Pengiriman::ringkas).orElse("tidak ada"));
System.out.println("Lacak EXP-003: "
+ ekspedisi.lacak("EXP-003").map(Pengiriman::ringkas).orElse("tidak ada"));
}
}
// Value object: berat dalam gram, tidak dapat diubah
record Berat(int gram) {
Berat {
if (gram <= 0) {
throw new IllegalArgumentException("berat harus positif: " + gram);
}
}
}
// Value object: ongkir dalam rupiah
record Ongkir(long rupiah) {
Ongkir tambah(Ongkir lain) {
return new Ongkir(rupiah + lain.rupiah);
}
}
// Entity: identitasnya nomor resi, bukan isi field
class Pengiriman {
private final String nomorResi;
private final String pengirim;
private final String penerima;
private final Berat berat;
private final Ongkir ongkir;
Pengiriman(String nomorResi, String pengirim, String penerima,
Berat berat, Ongkir ongkir) {
this.nomorResi = nomorResi;
this.pengirim = pengirim;
this.penerima = penerima;
this.berat = berat;
this.ongkir = ongkir;
}
String nomorResi() {
return nomorResi;
}
String ringkas() {
return nomorResi + ": " + pengirim + " -> " + penerima
+ ", " + berat.gram() + " gram, Rp" + ongkir.rupiah();
}
}
// Domain service: aturan yang melibatkan beberapa objek domain
class LayananEkspedisi {
private final Map<String, Pengiriman> resi = new LinkedHashMap<>();
private int urutan = 0;
Pengiriman buatPengiriman(String pengirim, String penerima, int beratGram) {
urutan++;
Berat berat = new Berat(beratGram);
Ongkir ongkir = hitungOngkir(berat);
String nomor = String.format("EXP-%03d", urutan);
Pengiriman pengiriman = new Pengiriman(nomor, pengirim, penerima, berat, ongkir);
resi.put(nomor, pengiriman);
return pengiriman;
}
Optional<Pengiriman> lacak(String nomorResi) {
return Optional.ofNullable(resi.get(nomorResi));
}
private Ongkir hitungOngkir(Berat berat) {
long tarifPerSeratusGram = 2_000;
int blokSeratusGram = (berat.gram() + 99) / 100;
return new Ongkir(blokSeratusGram * tarifPerSeratusGram);
}
}Output yang Dihasilkan:
EXP-001: Budi -> Siti, 1500 gram, Rp30000
EXP-002: Dewi -> Budi, 500 gram, Rp10000
Lacak EXP-001: EXP-001: Budi -> Siti, 1500 gram, Rp30000
Lacak EXP-003: tidak adaMari kita bedah bahasa domain pada program tersebut:
- Baris 21-27 (
record Berat): value object dengan compact constructor — record Java mengevaluasi blok tanpa parameter ini sebelum field diisi, sehingga berat0atau negatif ditolak sejak objek pertama kali dibuat. Tidak ada lagi berat palsu beredar di kode. - Baris 30-36 (
record Ongkir): operasitambahmengembalikanOngkirbaru, bukan memodifikasi field. Nilai uang dijumlahkan sebagai nilai, bukan sebagailongtelanjang yang mudah tertukar dengan field lain. - Baris 39-66 (
class Pengiriman): entity ini disamakan lewatnomorResi— dua objek dengan resi sama adalah kiriman yang sama, sekalipun beratnya diubah. Perhatikan juga nama methodringkas()yang lahir dari bahasa tim, bukantoString()teknis semata. - Baris 88-93 (
hitungOngkir): aturan “ongkir dihitung per blok seratus gram, dibulatkan ke atas” tidak wajar dimilikiBeratmaupunOngkir, sehingga berdiam di domain serviceLayananEkspedisi— persis definisi domain service. - Baris 70-86 (
LayananEkspedisi):Map resiberperan sebagai repository sederhana — dunia luar melihat koleksi pengiriman seolah seluruhnya di memori.
Model taktis DDD layak dibedah satu per satu: bagaimana record Java membangun value object yang tegas, bagaimana entity menjaga identitas, dan bagaimana aggregate root menjaga invarian keranjang belanja. Itu tugas chapter berikutnya — Domain Modeling.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
BahasaDomain.java
Unduh berkasimport java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public class BahasaDomain {
public static void main(String[] args) {
LayananEkspedisi ekspedisi = new LayananEkspedisi();
Pengiriman pertama = ekspedisi.buatPengiriman("Budi", "Siti", 1_500);
Pengiriman kedua = ekspedisi.buatPengiriman("Dewi", "Budi", 500);
System.out.println(pertama.ringkas());
System.out.println(kedua.ringkas());
System.out.println("Lacak EXP-001: "
+ ekspedisi.lacak("EXP-001").map(Pengiriman::ringkas).orElse("tidak ada"));
System.out.println("Lacak EXP-003: "
+ ekspedisi.lacak("EXP-003").map(Pengiriman::ringkas).orElse("tidak ada"));
}
}
// Value object: berat dalam gram, tidak dapat diubah
record Berat(int gram) {
Berat {
if (gram <= 0) {
throw new IllegalArgumentException("berat harus positif: " + gram);
}
}
}
// Value object: ongkir dalam rupiah
record Ongkir(long rupiah) {
Ongkir tambah(Ongkir lain) {
return new Ongkir(rupiah + lain.rupiah);
}
}
// Entity: identitasnya nomor resi, bukan isi field
class Pengiriman {
private final String nomorResi;
private final String pengirim;
private final String penerima;
private final Berat berat;
private final Ongkir ongkir;
Pengiriman(String nomorResi, String pengirim, String penerima,
Berat berat, Ongkir ongkir) {
this.nomorResi = nomorResi;
this.pengirim = pengirim;
this.penerima = penerima;
this.berat = berat;
this.ongkir = ongkir;
}
String nomorResi() {
return nomorResi;
}
String ringkas() {
return nomorResi + ": " + pengirim + " -> " + penerima
+ ", " + berat.gram() + " gram, Rp" + ongkir.rupiah();
}
}
// Domain service: aturan yang melibatkan beberapa objek domain
class LayananEkspedisi {
private final Map<String, Pengiriman> resi = new LinkedHashMap<>();
private int urutan = 0;
Pengiriman buatPengiriman(String pengirim, String penerima, int beratGram) {
urutan++;
Berat berat = new Berat(beratGram);
Ongkir ongkir = hitungOngkir(berat);
String nomor = String.format("EXP-%03d", urutan);
Pengiriman pengiriman = new Pengiriman(nomor, pengirim, penerima, berat, ongkir);
resi.put(nomor, pengiriman);
return pengiriman;
}
Optional<Pengiriman> lacak(String nomorResi) {
return Optional.ofNullable(resi.get(nomorResi));
}
private Ongkir hitungOngkir(Berat berat) {
long tarifPerSeratusGram = 2_000;
int blokSeratusGram = (berat.gram() + 99) / 100;
return new Ongkir(blokSeratusGram * tarifPerSeratusGram);
}
}