Domain-Driven Design for Microservices — System Design

DDD untuk Microservices Domain-Driven Design membantu menentukan batas microservice yang benar. Tanpa DDD, service boundaries seringkali arbitrary dan…

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 konser dan order membaca satu kolom harga yang sama, berapa total pesanan #1001 yang terbaca order sekarang? 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

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:

Strategic dulu, tactical kemudian

DDD punya dua lapis:

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

  1. Event storming: developer dan orang bisnis menulis kejadian domain di sticky note, misalnya KursiDitahan, KartuTerpotong, ETiketDipindai.
  2. Kelompokkan kejadian yang saling terkait: itu calon bounded context.
  3. Sepakati ubiquitous language per context, dan pakai di kode, dokumen, dan UI.
  4. Tentukan aggregate dan invariant-nya di tiap context.
  5. Gambar context map: di mana perlu ACL, siapa hulu dan hilir.
  6. Bangun sebagai modular monolith dulu; pecah jadi service saat organisasi membutuhkannya.

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari