Domain-Driven Design (DDD) adalah cara memodelkan software mengikuti bahasa bisnisnya, lalu membaginya jadi beberapa bounded context: wilayah yang punya model dan kamus kata sendiri. Untuk microservices, DDD menjawab pertanyaan paling mahal: batas service-nya di mana. Bayangin restoran besar. Di dapur, "ayam" adalah bahan masakan dengan berat dan cara potong. Di gudang, "ayam" adalah stok dengan kode barang dan tanggal kedaluwarsa. Di kasir, "ayam" adalah menu seharga Rp75.000. Ketiganya benar di bagiannya masing-masing. Kalau dipaksa satu catatan "ayam" untuk semua bagian, tiap perubahan di satu bagian merusak bagian lain.
🧪 Tebakan cepat: promotor menaikkan harga tiket VIP Konser Nusantara dari Rp750.000 jadi Rp900.000. Budi sudah lunas membayar 2 tiket VIP, total Rp1.500.000. Kalau service
konserdanordermembaca satu kolomhargayang sama, berapa total pesanan #1001 yang terbacaordersekarang? Tebak dulu, jawabannya ada di bagian "Satu kata, beda arti: tiket".
Bounded context dan ubiquitous language
Bounded context adalah batas tempat satu model berlaku. Di dalamnya, tiap kata punya satu arti yang disepakati developer dan orang bisnis. Kata-kata itu disebut ubiquitous language (bahasa bersama): dipakai sama persis di obrolan, dokumen, UI, dan nama class di kode. Kalau di kode namanya order, di dokumen "transaksi", dan di UI "pesanan", orang akan salah paham.
Di TiketNada ada lima context, sama dengan service yang sudah kita kenal: akun (login), konser (event dan kursi), order (pesanan), bayar (pembayaran), dan tiket (e-tiket dan gerbang masuk).
Satu kata, beda arti: tiket
Kata "tiket" dipakai tiga context, tapi artinya beda:
| Context | Namanya di model | Isinya | "harga" artinya | "status" artinya |
|---|---|---|---|---|
konser |
Kursi |
A-12, kategori VIP | harga jual hari ini | TERSEDIA, DITAHAN, TERJUAL |
order |
ItemPesanan |
baris di pesanan #1001 | harga yang dibayar Budi | (ikut status pesanan) |
tiket |
ETiket |
izin masuk gerbang, nama pemegang | (gak ada) | AKTIF, DIPINDAI |
Sekarang bayangin ketiganya berbagi satu tabel tiket dengan kolom harga dan status. VIP punya 500 kursi, 498 terjual (termasuk A-12 dan A-13 milik Budi), sisa 2.
| Kejadian | Satu tabel bersama | Model per context |
|---|---|---|
| Harga VIP naik jadi Rp900.000 | total #1001 = 2 × Rp900.000 = Rp1.800.000, padahal dibayar Rp1.500.000 | Kursi.harga Rp900.000, ItemPesanan.hargaSaatBeli tetap Rp750.000, total Rp1.500.000 |
| Gerbang memindai A-12 | status A-12 jadi DIPINDAI, bukan TERJUAL: sisa VIP terhitung 3, A-12 bisa dijual lagi | ETiket.status DIPINDAI, Kursi.status tetap TERJUAL, sisa VIP 2 |
Jawaban tebakannya: Rp1.800.000. Budi bisa dapat tagihan "kurang bayar Rp300.000" untuk pesanan yang sudah lunas. Masalahnya bukan bug di satu baris kode, tapi satu kolom yang dipaksa menyimpan dua arti. Dengan model per context, order membekukan harga saat beli, dan tiket punya status sendiri.
Satu bounded context, satu service
Idealnya satu bounded context jadi satu service, dengan database sendiri. Context lain gak membaca tabelnya; mereka bertanya lewat API atau mendengar event, dan cukup menyimpan ID-nya (user_id, kursi: "A-12"). Database yang dibagi antar context membuat batasnya palsu: perubahan di satu service merusak yang lain, seperti tabel tiket tadi.
Hal yang sama untuk "user". Di akun isinya email dan passwordHash; di order isinya nama dan email tujuan e-tiket; di bayar isinya token kartu. Itu wajar: tiap context punya model user sendiri dan gak berbagi database. Beri nama berbeda supaya jelas, misalnya AkunUser, Pembeli, PemegangKartu. Satu class User berisi semua field untuk semua context disebut god model, dan semua tim jadi bergantung padanya.
Aggregate: penjaga aturan
Aggregate adalah sekelompok objek yang diperlakukan sebagai satu unit dan dijaga satu pintu, yaitu aggregate root. Semua perubahan lewat method-nya, sehingga aturan bisnis (disebut invariant) gak bisa dilanggar. Pesanan TiketNada punya dua aturan: maksimal 4 tiket per pesanan, dan pesanan kosong gak bisa di-submit.
// Aggregate Pesanan: aturannya dijaga oleh Pesanan sendiri
class Pesanan {
#tiket; // privat: cuma bisa diubah lewat method di bawah
constructor(id, jumlah) {
this.id = id;
this.#tiket = jumlah;
this.status = "DRAFT";
}
tambahTiket(n) {
if (this.#tiket + n > 4) throw new Error("Pesanan maksimal 4 tiket");
this.#tiket += n;
}
submit() {
if (this.#tiket === 0) throw new Error("Pesanan kosong gak bisa di-submit");
this.status = "MENUNGGU_BAYAR";
}
get jumlah() {
return this.#tiket;
}
}
const p = new Pesanan(1002, 3); // draft Siti: 3 tiket
p.tambahTiket(1); // checkout: jadi 4
try {
p.tambahTiket(3); // import admin: lewat method yang sama
} catch (e) {
console.log("ditolak:", e.message); // ditolak: Pesanan maksimal 4 tiket
}
p.submit();
console.log(p.jumlah, p.status); // 4 MENUNGGU_BAYAR
#tiketadalah private field: kode di luar class gak bisa mengubahnya langsung.tambahTiketmemeriksa aturan sebelum mengubah apa pun. Jalur checkout dan jalur import admin sama-sama lewat sini.submitmenolak pesanan kosong dan memindah status jadiMENUNGGU_BAYAR.
Kebalikannya adalah anemic domain model: class Pesanan cuma berisi data (getter dan setter), dan aturannya ditulis di OrderService. Kelihatannya rapi, tapi jalur lain, misalnya import admin yang menulis pesanan.tiket langsung, melewati aturannya. Di Lab, import admin membuat pesanan #1002 berisi 6 tiket. Inti DDD: logic tinggal di domain object, bukan tersebar di service.
Context map: hubungan antar context
Context map menggambar bagaimana context saling bergantung. Empat hubungan yang paling sering:
- Customer-Supplier: satu context bergantung pada output context lain.
order(customer, di hilir) memakai APITahanKursimilikkonser(supplier, di hulu), dan timkonserikut merencanakan kebutuhanorder. - Anti-Corruption Layer (ACL): lapisan penerjemah di tepi.
bayarmenerima webhook payment gateway luar yang bilangtransaction_status: "settlement"; ACL menerjemahkannya jadiPembayaranberstatus BERHASIL, supaya istilah gateway gak menyebar ke kodebayar. - Published Language: format pertukaran yang disepakati dan dipublikasikan, misalnya skema JSON event
PesananDibayarberversi yang dibacatiketdan laporan. Kontrak OpenAPI atau protobuf juga termasuk. - Shared Kernel: dua context berbagi sebagian kecil model, misalnya modul
Uangmilikorderdanbayar. Hemat, tapi tiap perubahan butuh persetujuan dua tim, jadi jaga sekecil mungkin.
Strategic dulu, tactical kemudian
DDD punya dua lapis:
- Strategic DDD (paling penting): bounded context, ubiquitous language, context map. Inilah yang menentukan batas service.
- Tactical DDD (opsional): entity, value object, aggregate, repository, domain event. Ini cara menulis kode di dalam satu context.
Tim yang rapi memakai value object dan repository, tapi tiap fitur baru tetap harus mengubah banyak service sekaligus, biasanya melewatkan bagian strategic: batas context-nya salah. Tactical tanpa strategic itu ribet tapi gak menolong.
DDD juga gak mewajibkan microservices. Modular monolith dengan bounded context yang jelas (lesson Microservices vs Monolith) sering jadi titik terbaik. Urutannya: DDD dulu, microservices kalau memang perlu. Begitu perlu, context yang batasnya sudah jelas itulah yang dipindah satu per satu (lesson Monolith to Microservices).
🎯 Urutan memulai DDD
- Event storming: developer dan orang bisnis menulis kejadian domain di sticky note, misalnya KursiDitahan, KartuTerpotong, ETiketDipindai.
- Kelompokkan kejadian yang saling terkait: itu calon bounded context.
- Sepakati ubiquitous language per context, dan pakai di kode, dokumen, dan UI.
- Tentukan aggregate dan invariant-nya di tiap context.
- Gambar context map: di mana perlu ACL, siapa hulu dan hilir.
- Bangun sebagai modular monolith dulu; pecah jadi service saat organisasi membutuhkannya.
⚠️ Jebakan yang sering ketemu
- Database dibagi antar bounded context. Batasnya palsu: satu kolom
hargaataustatusdipaksa menyimpan dua arti. - "User" jadi god model. Beri tiap context modelnya sendiri:
AkunUser,Pembeli,PemegangKartu. - Anemic domain model. Aturan di service gak menjaga jalur lain. Taruh di aggregate.
- Tactical tanpa strategic. Value object dan repository yang rapi gak menolong kalau batas context-nya salah.
- Bahasa yang gak konsisten. "order" di kode, "transaksi" di dokumen, "pesanan" di UI berarti tiga orang membayangkan tiga hal.
TL;DR: DDD memodelkan software mengikuti bahasa bisnis dan membaginya jadi bounded context, tiap context dengan model dan ubiquitous language sendiri. Di TiketNada, "tiket" adalah Kursi di konser, ItemPesanan di order, dan ETiket di tiket. Kalau ketiganya berbagi satu tabel, harga VIP Rp900.000 membuat pesanan Budi yang lunas terbaca Rp1.800.000, dan e-tiket yang dipindai membuat kursi A-12 terhitung kosong. Idealnya satu context jadi satu service dengan database sendiri; model user yang berbeda per context itu wajar. Aggregate menjaga invariant lewat method-nya (Pesanan.tambahTiket menolak tiket ke-5); aturan yang ditaruh di service (anemic domain model) bisa dilewati jalur lain. Context map memetakan hubungan: Customer-Supplier, Anti-Corruption Layer, Published Language, Shared Kernel. Strategic DDD (batas context) lebih penting dari tactical DDD, dan DDD dulu, microservices kalau perlu.