Aplikasi yang bertahan lama biasanya punya bahasa sendiri: pesanan, faktur, retur, kasir, gudang. Masalah muncul ketika bahasa itu tidak pernah diangkat secara sadar ke dalam kode — class bernama Order dipakai untuk maksud yang berbeda-beda, aturan bisnis tersebar di controller, dan programmer baru harus menebak-nebak kosakata tim. Domain-Driven Design (DDD) adalah pendekatan yang menjadikan bahasa dan batas bisnis sebagai fondasi desain perangkat lunak. Pada chapter ini kita akan mempelajari sisi strategisnya (bounded context dan ubiquitous language), sisi taktisnya (value object hingga aggregate), lalu meluruskan mitos bahwa DDD selalu berat.
DDD Strategis: Bounded Context dan Ubiquitous Language #
Inti sisi strategis DDD adalah pengamatan sederhana: kata yang sama berarti hal berbeda di bagian organisasi yang berbeda. Kata “Order” (pesanan) bagi tim penjualan, tim pengiriman, dan tim keuangan mengacu pada tiga konsep yang berbeda data dan perilakunya. Bounded context adalah batas eksplisit di dalam setiap konsep memiliki satu makna tunggal, dan di dalam batas itu tim berbicara dengan ubiquitous language — kosakata yang dipakai sama oleh orang bisnis dan kode:
| Konteks | “Order” berarti | Data kunci | Tidak dipedulikan |
|---|---|---|---|
| Penjualan | Keranjang belanja dan statusnya | sku, qty, total, status | Berat paket, jatuh tempo |
| Pengiriman | Paket fisik yang harus dikirim | alamat, berat, kurir | Metode pembayaran |
| Keuangan | Dokumen tagihan | jumlah, pajak, jatuh tempo | Daftar barang detail |
Jika ketiga konteks ini berbagi satu class Order raksasa, class tersebut menampung kolom yang hanya relevan bagi sebagian pemakai dan aturan yang saling bertabrakan. Context map kemudian menggambarkan bagaimana konteks-konteks tersebut berhubungan — misalnya konteks Pengiriman berlangganan event OrderPlaced dari konteks Penjualan, persis pola komunikasi yang kita bangun pada chapter Event-Driven Architecture
. Kita juga sudah melihat batas konteks dalam bentuk direktori pada chapter Modular Monolith
: modul Ordering, modul Billing — DDD strategis adalah nama resmi untuk disiplin memotong tersebut.
DDD Taktis: Blok Bangunan Domain #
Di dalam sebuah bounded context, DDD menyediakan kosakata taktis untuk menulis model. Kita telah berkenalan dengan sebagian besar bentuknya pada chapter Domain Modeling ; tabel berikut merangkum perannya:
| Blok bangunan | Peran | Contoh |
|---|---|---|
| Value object | Nilai tanpa identitas, dibandingkan dari isinya | Uang, Alamat |
| Entity | Objek beridentitas yang berubah dari waktu ke waktu | Order dengan nomor pesanan |
| Aggregate | Cluster entity + value object dengan satu akar sebagai pintu masuk | Order berisi OrderItem |
| Domain event | Fakta penting yang terjadi di domain | OrderPlaced |
| Domain service | Operasi domain yang tidak alami milik satu entity | KalkulatorPajak |
Perbedaan paling fundamental ada di baris pertama. Entity dibandingkan lewat identitas (Order nomor 2041 tetap Order 2041 walau isinya berubah), sedangkan value object dibandingkan lewat isi: Uang(50000, 'IDR') sama dengan Uang(50000, 'IDR') lainnya dan tidak peduli yang mana.
Value Object yang Menjaga Dirinya Sendiri #
Value object adalah cara paling murah dan paling berdampak memulai DDD taktis di PHP: ia tidak butuh framework, tidak butuh database, dan bisa diuji sebagai fungsi murni. Mari kita tulis value object Uang yang membawa invariant (ketentuan yang tidak boleh dilanggar) di dalam konstruktornya:
<?php
final class Uang
{
public function __construct(
public readonly int $amountCents,
public readonly string $currency = 'IDR',
) {
if ($amountCents < 0) {
throw new InvalidArgumentException('Nominal tidak boleh negatif');
}
}
public function tambah(Uang $lain): Uang
{
if ($lain->currency !== $this->currency) {
throw new InvalidArgumentException(
"Mata uang berbeda: {$this->currency} vs {$lain->currency}"
);
}
return new self($this->amountCents + $lain->amountCents, $this->currency);
}
public function samaDengan(Uang $lain): bool
{
return $this->amountCents === $lain->amountCents
&& $this->currency === $lain->currency;
}
public function __toString(): string
{
return $this->currency . ' ' . number_format($this->amountCents, 0, ',', '.');
}
}
$subtotal = new Uang(12500000);
$ongkir = new Uang(150000);
$total = $subtotal->tambah($ongkir);
echo "Total: $total\n";
var_dump($total->samaDengan(new Uang(12650000)));
var_dump($total->samaDengan(new Uang(12650000, 'USD')));
try {
new Uang(-500);
} catch (InvalidArgumentException $e) {
echo "Galat: " . $e->getMessage() . "\n";
}Output yang Dihasilkan:
Total: IDR 12.650.000
bool(true)
bool(false)
Galat: Nominal tidak boleh negatifMari kita bedah perilaku Uang di atas:
- Constructor sebagai penjaga invariant: nominal negatif ditolak di satu-satunya pintu pembuatan objek. Tidak ada lagi pemeriksaan
if ($harga >= 0)yang tersebar di seluruh codebase — mustahil membuatUangyang tidak sah. readonlyproperties: objek tidak bisa diubah setelah dibuat. Operasitambah()mengembalikan instance baru alih-alih memodifikasi dirinya, sehingga satuUangaman dibagikan ke mana pun tanpa risiko diubah diam-diam.samaDengan(): perbandingan berdasarkan isi, bukan identitas objek. Dua kembaliannew Uang(12650000)adalah nilai yang sama meski objeknya berbeda — tetapiUSDtentu bukanIDR.__toString(): representasi tampilan konsisten. Perhatikan bahwa nominal disimpan sebagai integeramountCents, menghindari seluruh kelas bug presisi floating point sejak desain.
Ketika tim menulis function hitungDiskon(Uang $harga): Uang alih-alih function hitungDiskon(int $hargaCents, string $mataUang), compiler ikut menjaga kebenaran: argumen tertukar antara nominal dan mata uang tidak akan pernah lolos.
Anemik versus Kaya #
Banyak aplikasi PHP berhenti di model anemik: class hanya menampung data publik, dan seluruh logika hidup di service raksasa yang membaca-mutasi field satu per satu. Mari kita lihat kedua wujud itu bersebelahan:
<?php
// Model anemik: wadah data pasif, aturan hidup di service lain.
final class OrderAnemik
{
public string $status = 'draft';
public array $items = [];
}
// Model kaya: menjaga aturannya sendiri.
final class OrderKaya
{
private string $status = 'draft';
/** @var list<array{sku: string, qty: int}> */
private array $items = [];
public function tambahItem(string $sku, int $qty): void
{
if ($qty < 1) {
throw new InvalidArgumentException('Qty minimal 1');
}
$this->items[] = ['sku' => $sku, 'qty' => $qty];
}
public function place(): void
{
if ($this->items === []) {
throw new DomainException('Pesanan kosong tidak bisa diproses');
}
$this->status = 'placed';
}
}Pada model anemik, tidak ada yang mencegah $order->status = 'shipped' ditulis dari tempat mana pun — dan setiap aturan status harus diingat oleh semua programmer. Pada model kaya, place() sendiri yang menolak pesanan kosong, sehingga mustahil ada pesanan placed tanpa item di seluruh aplikasi. Aturan hidup tepat di samping data yang diaturnya.
DDD Tidak Harus Berat #
Mitos terbesar tentang DDD adalah bahwa ia menuntut proyek raksasa dengan tim ahli penuh waktu. Kenyataannya, DDD punya dua tingkat keterlibatan yang bisa dipilih terpisah. Tingkat ringan — dan hampir selalu layak — adalah bahasa yang disiplin: nama class, method, dan variabel mengikuti kosakata yang dipakai orang bisnis, plus batas modul yang tegas. Tingkat berat — aggregate dengan boundary yang ketat, domain event, repository per aggregate — masuk bertahap, hanya pada bagian sistem yang kompleksitasnya benar-benar menuntut.
Tim PHP yang praktis biasanya iterate: mulai dari model anemik yang jujur, lalu menarik aturan masuk ke value object dan entity setiap kali bug menunjukkan bahwa logika tersebar mulai melukai. Untuk modul CRUD murni — tabel admin sederhana tanpa aturan — DDD taktis memang overkill: sebuah UPDATE statement dan form adalah desain terbaiknya. Kecerdasan arsitektur ada di mengetahui bagian mana dari aplikasi Anda yang bukan CRUD.
Ringkasan #
Domain-Driven Design memulai dari bahasa: bounded context memberi tiap kosakata satu makna tunggal, dan ubiquitous language menyatukan cara orang bisnis dan kode menyebut hal yang sama. Di level taktis, value object seperti Uang membuktikan bahwa menjaga invariant di dalam type itu murah dan segera terasa dampaknya, sementara model kaya memastikan aturan tinggal di rumahnya sendiri. Semua keputusan yang kita ambil sepanjang section ini — modular monolith atau microservices, event atau panggilan langsung, value object atau array asosiatif — akan hilang alasannya begitu orangnya pergi, kecuali satu hal: kita menuliskannya. Kebiasaan mendokumentasikan keputusan itulah topik penutup section ini, Architecture Decision Records.