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 bisnisUngkapan dalam kodeBlok DDD
“resi”Pengiriman dengan nomorResiEntity
“berat kiriman”Berat (gram)Value object
“ongkir”Ongkir (rupiah)Value object
“buat resi, hitung ongkir”LayananEkspedisi.buatPengirimanDomain 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:

BlokSifatContoh
Value objectTanpa identitas; dibandingkan berdasarkan nilai; tidak dapat diubahBerat, Ongkir
EntityBeridentitas; dua objek dengan ID sama dianggap samaPengiriman
AggregateKumpulan entity dengan satu aggregate root penjaga invarianKeranjangBelanja
Domain serviceOperasi domain yang tidak wajar dimiliki satu entityhitungOngkir lintas tarif
RepositoryKoleksi aggregate seolah ada di memoriLayananEkspedisi.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:

text
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 ada

Mari kita bedah bahasa domain pada program tersebut:

  1. Baris 21-27 (record Berat): value object dengan compact constructor — record Java mengevaluasi blok tanpa parameter ini sebelum field diisi, sehingga berat 0 atau negatif ditolak sejak objek pertama kali dibuat. Tidak ada lagi berat palsu beredar di kode.
  2. Baris 30-36 (record Ongkir): operasi tambah mengembalikan Ongkir baru, bukan memodifikasi field. Nilai uang dijumlahkan sebagai nilai, bukan sebagai long telanjang yang mudah tertukar dengan field lain.
  3. Baris 39-66 (class Pengiriman): entity ini disamakan lewat nomorResi — dua objek dengan resi sama adalah kiriman yang sama, sekalipun beratnya diubah. Perhatikan juga nama method ringkas() yang lahir dari bahasa tim, bukan toString() teknis semata.
  4. Baris 88-93 (hitungOngkir): aturan “ongkir dihitung per blok seratus gram, dibulatkan ke atas” tidak wajar dimiliki Berat maupun Ongkir, sehingga berdiam di domain service LayananEkspedisi — persis definisi domain service.
  5. Baris 70-86 (LayananEkspedisi): Map resi berperan 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 berkas
java
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);
    }
}