Layered architecture, dependency injection, dan interface membenahi batas-batas luar aplikasi — siapa memanggil siapa dan lewat kontrak apa. Domain modeling bekerja di dalam wilayah itu: bagaimana konsep bisnis seperti pesanan, faktur, dan uang dibentuk menjadi kode yang berperilaku, bukan sekadar wadah kolom database. Pendekatan yang kita pakai adalah pengenalan ringan pada gagasan Domain-Driven Design (DDD) — sekelompok praktik yang menempatkan model domain sebagai pusat desain. Pada chapter ini kita membangun dua wujudnya di PHP 8.4: value object Uang yang readonly dan entity Faktur yang menghitung totalnya sendiri, lalu membandingkannya dengan model yang hanya berisi data.

Entity, Value Object, dan Aggregate #

Tiga istilah DDD paling sering dipakai dalam pekerjaan sehari-hari. Pemetaannya terhadap contoh kita begini:

KonsepCiri khasContoh
Entitypunya identitas, boleh berubah, dibandingkan lewat identitasFaktur bernomor INV-2026-0042
Value objecttanpa identitas, immutable, dibandingkan lewat nilaiUang senilai Rp275.000
Aggregatekelompok object yang perubahannya diatur satu pintuFaktur beserta baris-barisnya

Dua Faktur dengan nomor yang sama adalah faktur yang sama meskipun barisnya bertambah — itulah identitas. Sebaliknya, dua Uang senilai 275.000 rupiah saling menggantikan tanpa ada yang bertanya “uang yang mana” — yang ada hanya nilainya. Aggregate menjawab pertanyaan konsistensi: dunia luar hanya boleh mengubah isi faktur lewat satu pintu, yaitu method milik Faktur sebagai aggregate root, sehingga aturan seperti “kuantitas minimal satu” tidak mungkin dilewati. DDD masih memiliki istilah lain seperti domain event dan bounded context; kita ambil tiga yang paling berdampak sehari-hari.

Value Object Uang, Kebenaran yang Tidak Bisa Rusak #

Masalah klasik aplikasi keuangan adalah uang yang hidup sebagai integer mentah atau, lebih berbahaya, sebagai float. Value object membungkus jumlah dan mata uang menjadi satu nilai yang selalu benar sejak lahir:

php
<?php
enum MataUang: string
{
    case Rupiah = "IDR";
    case Dolar = "USD";
}

final class Uang
{
    public function __construct(
        public readonly int $jumlah,
        public readonly MataUang $mataUang,
    ) {
    }

    public function tambah(Uang $lain): Uang
    {
        if ($lain->mataUang !== $this->mataUang) {
            throw new InvalidArgumentException(
                "Tidak bisa menjumlahkan {$this->mataUang->value} dengan {$lain->mataUang->value}"
            );
        }

        return new Uang($this->jumlah + $lain->jumlah, $this->mataUang);
    }

    public function kalikan(int $faktor): Uang
    {
        return new Uang($this->jumlah * $faktor, $this->mataUang);
    }

    public function samaDengan(Uang $lain): bool
    {
        return $this->jumlah === $lain->jumlah
            && $this->mataUang === $lain->mataUang;
    }

    public function format(): string
    {
        return match ($this->mataUang) {
            MataUang::Rupiah => "Rp" . number_format($this->jumlah, 0, ",", "."),
            MataUang::Dolar => "$" . number_format($this->jumlah / 100, 2),
        };
    }
}

Mari kita bedah peran tiap bagian di atas:

  1. enum MataUang adalah backed enum PHP 8.1: daftar mata uang menjadi tipe tertutup, sehingga MataUang::Rupiah tidak mungkin tertukar dengan string "IDR" yang salah ketik. Dasar enum telah kita bahas pada chapter Enums .
  2. Constructor promotion public readonly menjadikan Uang immutable: setelah lahir, jumlah dan mata uangnya tidak bisa diubah; setiap operasi menghasilkan instance baru. Perilaku ini dijelaskan pada chapter Readonly Properties dan Classes .
  3. tambah() menolak menjumlahkan dua mata uang berbeda dengan exception — aturan yang tadinya harus diingat setiap programmer kini dijaga oleh tipe itu sendiri.
  4. samaDengan() membandingkan berdasarkan nilai, menggantikan perbandingan identitas object yang tidak berlaku untuk value object.
  5. format() menyimpan pengetahuan tampilan rupiah dan dolar — perhatikan bahwa dolar disimpan dalam satuan sen lalu dibagi 100 saat tampil.

Seluruh definisi di atas tersimpan sebagai berkas faktur.php pada bundle chapter ini. Mari kita lihat value object ini bekerja:

php
<?php
require __DIR__ . "/faktur.php";

$hargaBuku = new Uang(250_000, MataUang::Rupiah);
$ongkir = new Uang(25_000, MataUang::Rupiah);

$total = $hargaBuku->tambah($ongkir);
echo $total->format(), "\n";
echo (new Uang(1500, MataUang::Dolar))->format(), "\n";

var_dump($total->samaDengan(new Uang(275_000, MataUang::Rupiah)));
var_dump($total->samaDengan($hargaBuku));

try {
    $hargaBuku->tambah(new Uang(1500, MataUang::Dolar));
} catch (InvalidArgumentException $e) {
    echo "Galat: ", $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
Rp275.000
$15.00
bool(true)
bool(false)
Galat: Tidak bisa menjumlahkan IDR dengan USD

Penjumlahan 250.000 dan 25.000 rupiah menghasilkan Uang baru senilai Rp275.000 — object lama tidak tersentuh. samaDengan() memastikan uang bernilai sama dianggap sama, meski instance-nya berbeda. Bagian terakhir membuktikan penjaga tipe bekerja: menjumlahkan rupiah dengan 1.500 sen dolar ditolak dengan pesan galat yang menyebut kedua mata uangnya.

Perilaku pada Model, Faktur yang Menghitung Sendiri #

Entity menampung data yang berubah sepanjang use case, dan kuncinya: menghitung sendiri hal-hal tentang dirinya. Total faktur adalah pengetahuan tentang faktur — maka tempatnya di Faktur, bukan di service luar yang membaca isi faktur lalu berhitung sendiri:

php
<?php
final class Faktur
{
    /** @var list<array{nama: string, harga: Uang, kuantitas: int}> */
    private array $baris = [];

    public function __construct(
        public readonly string $nomor,
        public readonly string $emailPelanggan,
    ) {
    }

    public function tambahBaris(string $nama, Uang $hargaSatuan, int $kuantitas): void
    {
        if ($kuantitas < 1) {
            throw new InvalidArgumentException("kuantitas minimal 1, diberikan $kuantitas");
        }
        $this->baris[] = [
            "nama" => $nama,
            "harga" => $hargaSatuan,
            "kuantitas" => $kuantitas,
        ];
    }

    public function jumlahBaris(): int
    {
        return count($this->baris);
    }

    public function total(): Uang
    {
        $total = new Uang(0, MataUang::Rupiah);
        foreach ($this->baris as $baris) {
            $total = $total->tambah($baris["harga"]->kalikan($baris["kuantitas"]));
        }

        return $total;
    }
}

Tiga keputusan penting terlihat di atas. Pertama, property $baris private: tidak ada kode luar yang bisa menulis array baris sembarangan, sehingga validasi kuantitas di tambahBaris() menjadi satu-satunya pintu masuk — inilah aggregate yang menjaga invariannya. Kedua, total() memakai aritmetika milik Uang (kalikan() lalu tambah()), bukan perkalian integer mentah; konsekuensinya, bila ada baris berbeda mata uang dimasukkan, tambah() otomatis melempar exception tanpa perlu pemeriksaan manual di mana-mana — invari “satu faktur satu mata uang” dijaga oleh value object. Ketiga, nomor dan emailPelanggan readonly karena keduanya adalah identitas yang tidak seharusnya berganti. Mari kita jalankan:

php
<?php
require __DIR__ . "/faktur.php";

$faktur = new Faktur("INV-2026-0042", "[email protected]");
$faktur->tambahBaris("Belajar PHP Dasar", new Uang(150_000, MataUang::Rupiah), 2);
$faktur->tambahBaris("Ongkos kirim", new Uang(25_000, MataUang::Rupiah), 1);

printf("faktur %s untuk %s\n", $faktur->nomor, $faktur->emailPelanggan);
printf("jumlah baris: %d\n", $faktur->jumlahBaris());
echo "total: ", $faktur->total()->format(), "\n";

Output yang Dihasilkan:

text
faktur INV-2026-0042 untuk [email protected]
jumlah baris: 2
total: Rp325.000

Dua buku senilai 150.000 rupiah ditambah ongkos kirim 25.000 rupiah dijumlahkan menjadi Rp325.000 — perhitungan hidup di dalam model, dan pemanggil hanya bertanya. Bandingkan dengan versi anemik yang kita lihat sejenak berikutnya: total dihitung di luar, dan setiap penambah use case baru harus mengingat sendiri cara menghitung ulang.

Model Anemik atau Model Kaya #

Model anemik (anemic domain model) adalah kebalikannya: class hanya menampung data public, sementara seluruh aturan hidup di class service terpisah. Versi anemik untuk pembatalan pesanan tampak seperti ini:

php
<?php

// Model anemik: hanya wadah data; aturannya hidup di tempat lain.
final class PesananAnemik
{
    public function __construct(
        public string $status = "baru",
        public ?DateTimeImmutable $dibatalkanPada = null,
    ) {
    }
}

final class PembatalPesanan
{
    public function batalkan(PesananAnemik $pesanan): void
    {
        // aturan bisnis menempel di luar model yang datanya dipakai
        if ($pesanan->status === "selesai") {
            throw new RuntimeException("pesanan selesai tidak bisa dibatalkan");
        }
        $pesanan->status = "batal";
        $pesanan->dibatalkanPada = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
    }
}

Masalahnya bukan sintaks, melainkan kepemilikan: siapa pun dapat menulis $pesanan->status = "selesai"; tanpa melewati pemeriksaan apa pun, dan aturan pembatalan hanya hidup selama semua programmer mengingat untuk memanggil PembatalPesanan. Versi kaya memindahkan aturan ke dalam entity:

php
<?php
final class Pesanan
{
    public function __construct(
        public readonly string $nomor,
        private string $status = "baru",
        private ?DateTimeImmutable $dibatalkanPada = null,
    ) {
    }

    public function tandaiSelesai(): void
    {
        $this->status = "selesai";
    }

    public function batalkan(DateTimeImmutable $waktu): void
    {
        if ($this->status === "selesai") {
            throw new DomainException("pesanan {$this->nomor} sudah selesai");
        }
        $this->status = "batal";
        $this->dibatalkanPada = $waktu;
    }

    public function status(): string
    {
        return $this->status;
    }
}

$waktu = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));

$pesanan = new Pesanan("PSN-77");
$pesanan->batalkan($waktu);
echo "status: ", $pesanan->status(), "\n";

$selesai = new Pesanan("PSN-78");
$selesai->tandaiSelesai();
try {
    $selesai->batalkan($waktu);
} catch (DomainException $e) {
    echo "Galat: ", $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
status: batal
Galat: pesanan PSN-78 sudah selesai

status kini private dengan pintu perubahan bernama jelas; pembatalan pesanan PSN-77 berhasil, sedangkan PSN-78 yang sudah selesai ditolak entity-nya sendiri. Tidak ada lagi jalur pintas untuk merusak status, dan pembaca class langsung melihat daftar hal yang boleh terjadi pada sebuah pesanan.

Bahasa yang Sama dengan Ahlinya #

Gagasan ubiquitous language menyatakan satu hal praktis: istilah yang dipakai tim bisnis harus menjadi nama di kode, satu-untuk-satu. Ketika staf gudang Budi berkata “faktur”, kode juga harus menyebut Faktur — bukan invoice_record atau trx_header. Penerjemahan bolak-balik antara bahasa bisnis dan bahasa kode adalah tempat salah paham bersembunyi. Pemetaan contoh kita:

Ucapan tim bisnisIdentifier kode
“faktur ini untuk siapa?”Faktur::$emailPelanggan
“tambahkan satu baris ke faktur”Faktur::tambahBaris()
“berapa total faktur ini?”Faktur::total()
“nominalnya berapa?”Uang::format()

Manfaatnya nyata saat aturan berubah. Ketika manajemen mengumumkan “total faktur sekarang kena pajak”, percakapan itu langsung memetakan ke satu method yang harus disentuh — Faktur::total() — tanpa perlu berburu di lima service. Bila sebaliknya Anda mendapati istilah bisnis tanpa padanan kode, atau identifier kode tanpa padanan ucapan bisnis, itu sinyal model belum menangkap domainnya dengan utuh.

Ringkasan #

Domain modeling menempatkan konsep bisnis sebagai kode berperilaku: value object seperti Uang menyimpan kebenaran yang tidak bisa rusak, entity seperti Faktur membawa identitas dan menghitung sendiri totalnya, dan aggregate menjaga invarian lewat satu pintu perubahan. Model anemik kadang memadai, tetapi semakin tebal aturan bisnis, semakin terbayar model yang kaya. Semua object ini hidup di lapisan domain pada Layered Architecture . Namun ketika data harus melintasi batas lapisan — dari kiriman HTTP ke service, atau dari service ke respons API — mengirim entity apa adanya bukanlah pilihan yang aman; chapter Data Transfer Objects membahas kemasan khusus untuk perjalanan semacam itu.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

faktur.php

Unduh berkas
php
<?php

// Bundle chapter Domain Modeling: value object Uang dan entity Faktur.

enum MataUang: string
{
    case Rupiah = "IDR";
    case Dolar = "USD";
}

final class Uang
{
    public function __construct(
        public readonly int $jumlah,
        public readonly MataUang $mataUang,
    ) {
    }

    public function tambah(Uang $lain): Uang
    {
        if ($lain->mataUang !== $this->mataUang) {
            throw new InvalidArgumentException(
                "Tidak bisa menjumlahkan {$this->mataUang->value} dengan {$lain->mataUang->value}"
            );
        }

        return new Uang($this->jumlah + $lain->jumlah, $this->mataUang);
    }

    public function kalikan(int $faktor): Uang
    {
        return new Uang($this->jumlah * $faktor, $this->mataUang);
    }

    public function samaDengan(Uang $lain): bool
    {
        return $this->jumlah === $lain->jumlah
            && $this->mataUang === $lain->mataUang;
    }

    public function format(): string
    {
        return match ($this->mataUang) {
            MataUang::Rupiah => "Rp" . number_format($this->jumlah, 0, ",", "."),
            MataUang::Dolar => "$" . number_format($this->jumlah / 100, 2),
        };
    }
}

final class Faktur
{
    /** @var list<array{nama: string, harga: Uang, kuantitas: int}> */
    private array $baris = [];

    public function __construct(
        public readonly string $nomor,
        public readonly string $emailPelanggan,
    ) {
    }

    public function tambahBaris(string $nama, Uang $hargaSatuan, int $kuantitas): void
    {
        if ($kuantitas < 1) {
            throw new InvalidArgumentException("kuantitas minimal 1, diberikan $kuantitas");
        }
        $this->baris[] = [
            "nama" => $nama,
            "harga" => $hargaSatuan,
            "kuantitas" => $kuantitas,
        ];
    }

    public function jumlahBaris(): int
    {
        return count($this->baris);
    }

    public function total(): Uang
    {
        $total = new Uang(0, MataUang::Rupiah);
        foreach ($this->baris as $baris) {
            $total = $total->tambah($baris["harga"]->kalikan($baris["kuantitas"]));
        }

        return $total;
    }
}