Domain modeling adalah proses menerjemahkan aturan bisnis menjadi struktur kode yang merepresentasikan entitas dunia nyata. Kotlin menyediakan beberapa fitur yang sangat mendukung pemodelan ini: value class untuk value object, data class untuk entity, dan visibility modifier untuk membatasi akses internal aggregate. Pada chapter ini kita akan membangun model domain untuk toko sederhana: value object Email dan JumlahUang, entity Pesanan sebagai aggregate root, dan bounded context Inventaris yang terpisah dari Penjualan. Pembaca akan melihat bagaimana setiap pilihan tipe membantu menegakkan aturan bisnis di tingkat kompilator.
Konsep Inti Domain Modeling #
Empat konsep yang menjadi pondasi pemodelan domain:
| Konsep | Ciri | Contoh |
|---|---|---|
| Value object | Tanpa identitas; dibandingkan dari nilai | Email, JumlahUang |
| Entity | Punya identitas; state bisa berubah | Pesanan, Pelanggan |
| Aggregate | Kumpulan entity yang konsisten bersama | Pesanan + ItemPesanan |
| Bounded context | Batas domain; istilah bisa berbeda konteks | Produk di Inventaris vs Penjualan |
Value object cocok direpresentasikan dengan value class karena ringan dan tidak perlu identitas. Entity membutuhkan id stabil untuk membandingkan kesetaraan; aggregate root membatasi akses langsung ke entity anak agar konsistensi tetap terjaga.
Value Object dan Entity di Kotlin #
// Demo domain modeling: value objects, entities, aggregates, bounded contexts.
import java.time.LocalDate
// === Value Object: tidak punya identitas, dibedakan dari nilai ===
@JvmInline
value class Email(val nilai: String) {
init {
require(nilai.contains("@")) { "Email tidak valid: $nilai" }
}
}
@JvmInline
value class JumlahUang(val nilai: Double) : Comparable<JumlahUang> {
operator fun plus(lain: JumlahUang) = JumlahUang(nilai + lain.nilai)
operator fun minus(lain: JumlahUang) = JumlahUang(nilai - lain.nilai)
override fun compareTo(other: JumlahUang): Int = nilai.compareTo(other.nilai)
}
data class TanggalPesanan(val tanggal: LocalDate)
// === Entity: punya identitas (id), mutable state terkontrol ===
data class ItemPesanan(val id: Int, val nama: String, val harga: JumlahUang, val jumlah: Int) {
fun subtotal(): JumlahUang = JumlahUang(harga.nilai * jumlah)
}
// === Aggregate Root: Pesanan adalah root; ItemPesanan ada di dalam aggregate ===
class Pesanan(val id: Int, val pelanggan: String) {
private val items = mutableListOf<ItemPesanan>()
private var status: StatusPesanan = StatusPesanan.DRAFT
val tanggalDibuat = TanggalPesanan(LocalDate.of(2026, 1, 15))
fun tambahItem(item: ItemPesanan) {
require(status == StatusPesanan.DRAFT) {
"Tidak bisa tambah item: status sudah $status"
}
items.add(item)
}
fun total(): JumlahUang = items.fold(JumlahUang(0.0)) { acc, i -> acc + i.subtotal() }
fun kirim() {
require(status == StatusPesanan.DRAFT) { "Pesanan sudah dikirim" }
require(items.isNotEmpty()) { "Tidak bisa kirim pesanan kosong" }
status = StatusPesanan.DIKIRIM
}
fun ringkas(): String = "Pesanan #$id ($pelanggan) ${items.size} item total=${total().nilai} status=$status"
}
enum class StatusPesanan { DRAFT, DIKIRIM, SELESAI }
// === Bounded Context: konteks Inventaris berbeda dari konteks Penjualan ===
// ItemPesanan di konteks Penjualan = harga + jumlah
// Item di konteks Inventaris = stok + lokasi rak
data class ItemInventaris(val sku: String, val stok: Int, val lokasiRak: String)
class LayananInventaris {
private val stok = mutableMapOf<String, Int>()
fun daftar(item: ItemInventaris) {
stok[item.sku] = (stok[item.sku] ?: 0) + item.stok
}
fun kurangi(sku: String, jumlah: Int): Boolean {
val saatIni = stok[sku] ?: return false
if (saatIni < jumlah) return false
stok[sku] = saatIni - jumlah
return true
}
fun stokSaatIni(sku: String): Int = stok[sku] ?: 0
}
fun main() {
println("=== Demo Domain Modeling ===")
// Value object
val email = Email("[email protected]")
println("Email valid: ${email.nilai}")
val harga = JumlahUang(15_000.0)
val tambahan = JumlahUang(5_000.0)
println("Harga + tambahan: ${(harga + tambahan).nilai}")
// Aggregate root
val p = Pesanan(101, "Budi")
p.tambahItem(ItemPesanan(1, "Buku", JumlahUang(15_000.0), 2))
p.tambahItem(ItemPesanan(2, "Pulpen", JumlahUang(5_000.0), 3))
println(p.ringkas())
p.kirim()
println(p.ringkas())
println()
println("--- Bounded Context: Inventaris ---")
val inv = LayananInventaris()
inv.daftar(ItemInventaris("BK001", 100, "Rak A1"))
inv.daftar(ItemInventaris("PL001", 50, "Rak A2"))
println("Stok awal BK001: ${inv.stokSaatIni("BK001")}")
inv.kurangi("BK001", 5)
println("Stok setelah kurangi 5: ${inv.stokSaatIni("BK001")}")
}Output yang Dihasilkan:
=== Demo Domain Modeling ===
Email valid: [email protected]
Harga + tambahan: 20000.0
Pesanan #101 (Budi) 2 item total=45000.0 status=DRAFT
Pesanan #101 (Budi) 2 item total=45000.0 status=DIKIRIM
--- Bounded Context: Inventaris ---
Stok awal BK001: 100
Stok setelah kurangi 5: 95Bedah Kode Baris-per-Baris #
Mari kita perhatikan bagian kunci:
- Baris 7-11 (
value class Email):@JvmInlinemeng-inlining value class pada JVM. Blokinitmemakairequireuntuk memvalidasi format email saat object dibuat, sehingga object invalid tidak bisa pernah ada. - Baris 14-19 (
value class JumlahUang): ImplementasiComparabledan operatorplus/minusmemungkinkan perhitungan uang tetap berada dalam tipe yang aman, bukanDoublementah yang rentan kesalahan. - Baris 27-49 (
class Pesanan): Aggregate root menyembunyikanitemsdanstatussebagai fieldprivate. MethodtambahItemdankirimmemeriksa invariant sebelum mengubah state. - Baris 32-35 (
require(status == StatusPesanan.DRAFT)): Invariant bisnis ditegakkan secara eksplisit. Menambah item ke pesanan yang sudah dikirim akan melemparkan exception dengan pesan jelas. - Baris 56 (
ItemInventarisvsItemPesanan): Bounded context Inventaris punya modelItemyang berbeda sama sekali dari Penjualan. Tidak ada godaan untuk berbagi satu classProdukyang membengkak dengan atribut dari semua konteks.
Bounded Context #
Bounded context memisahkan domain besar menjadi sub-domain yang lebih kecil dan koheren. Istilah “Produk” di Inventaris (sku, stok, lokasi rak) berbeda dari “Produk” di Penjualan (id, nama, harga jual). Memaksakan satu class untuk keduanya akan menghasilkan model yang membengkak dan rentan konflik atribut. Kotlin mendukung pemisahan ini secara alami lewat package dan visibility modifier.
Penutup #
Domain modeling mengubah aturan bisnis menjadi struktur kode yang dapat divalidasi oleh kompilator. Value object menegakkan format data sejak object dibuat; aggregate root menjaga konsistensi internal; bounded context mencegah model membengkak. Pada chapter berikutnya kita akan membahas Layered Architecture: bagaimana model domain ini ditempatkan di salah satu layer aplikasi.
Referensi #
Referensi Lanjutan #
- Kotlin documentation home
— halaman utama dokumentasi Kotlin untuk
value class,data class, dan sealed type saat memodelkan domain