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” berartiData kunciTidak dipedulikan
PenjualanKeranjang belanja dan statusnyasku, qty, total, statusBerat paket, jatuh tempo
PengirimanPaket fisik yang harus dikirimalamat, berat, kurirMetode pembayaran
KeuanganDokumen tagihanjumlah, pajak, jatuh tempoDaftar 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 bangunanPeranContoh
Value objectNilai tanpa identitas, dibandingkan dari isinyaUang, Alamat
EntityObjek beridentitas yang berubah dari waktu ke waktuOrder dengan nomor pesanan
AggregateCluster entity + value object dengan satu akar sebagai pintu masukOrder berisi OrderItem
Domain eventFakta penting yang terjadi di domainOrderPlaced
Domain serviceOperasi domain yang tidak alami milik satu entityKalkulatorPajak

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
<?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:

text
Total: IDR 12.650.000
bool(true)
bool(false)
Galat: Nominal tidak boleh negatif

Mari kita bedah perilaku Uang di atas:

  1. 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 membuat Uang yang tidak sah.
  2. readonly properties: objek tidak bisa diubah setelah dibuat. Operasi tambah() mengembalikan instance baru alih-alih memodifikasi dirinya, sehingga satu Uang aman dibagikan ke mana pun tanpa risiko diubah diam-diam.
  3. samaDengan(): perbandingan berdasarkan isi, bukan identitas objek. Dua kembalian new Uang(12650000) adalah nilai yang sama meski objeknya berbeda — tetapi USD tentu bukan IDR.
  4. __toString(): representasi tampilan konsisten. Perhatikan bahwa nominal disimpan sebagai integer amountCents, 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
<?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.

Referensi #