Tampilan
Kenapa penting
Bab sebelumnya menyimpulkan bahwa batas service yang salah jauh lebih mahal daripada monolith yang rapi. Pertanyaan berikutnya: di mana garis batasnya? Domain-Driven Design (DDD) memberi alat untuk menjawabnya dari sisi bisnis, bukan dari sisi tabel database. Di pekerjaan, konsep seperti bounded context, aggregate, dan domain event muncul saat merancang service baru, saat review desain, dan saat interview system design. Kamu tidak perlu menguasai seluruh buku DDD — versi "DDD-lite" di bab ini sudah cukup untuk membuat keputusan dekomposisi yang masuk akal.
Konsep inti
Ubiquitous language
DDD dimulai dari bahasa. Ubiquitous language adalah kosakata bersama antara developer dan orang bisnis yang dipakai konsisten di percakapan, dokumen, dan kode. Bila tim bisnis bilang "pesanan dikonfirmasi", kode sebaiknya punya order.confirm(), bukan order.setStatus(2).
java
// Kurang komunikatif — istilah teknis, aturan bisnis tersembunyi
order.setStatus(2);
order.setFlag(true);
// Ubiquitous language — terbaca seperti kalimat bisnis
order.confirm();
order.markAsShipped(trackingNumber);Bahasa yang konsisten membuat aturan bisnis terlihat di kode dan mengurangi salah paham — terutama saat istilah yang sama ternyata berarti hal berbeda di bagian lain organisasi.
Bounded context dan context map
Di toko online, kata "produk" berarti berbeda bagi setiap tim:
| Context | Arti "produk" | Data yang penting |
|---|---|---|
| Katalog | Barang yang dipajang | nama, deskripsi, foto, kategori, harga tampil |
| Inventaris | Barang fisik di gudang | SKU, jumlah stok, lokasi rak |
| Pengiriman | Paket yang dikirim | berat, dimensi, mudah pecah atau tidak |
| Pesanan | Baris yang dibeli | SKU, jumlah, harga saat dibeli |
Memaksa semua arti itu ke satu class Product menghasilkan model raksasa yang disentuh semua tim. Bounded context adalah batas eksplisit tempat satu model dan bahasanya berlaku konsisten. Di dalam context Pengiriman, "produk" cukup punya berat dan dimensi; context lain merujuknya lewat identitas bersama (SKU), bukan lewat class atau tabel bersama.
Hubungan antar-context digambarkan di context map. Beberapa pola hubungan yang sering muncul:
| Pola | Arti singkat |
|---|---|
| Customer–Supplier | Context hulu (supplier) menyediakan data/API yang dibutuhkan context hilir (customer) |
| Conformist | Hilir menerima model hulu apa adanya, tanpa negosiasi |
| Anti-Corruption Layer (ACL) | Hilir menerjemahkan model hulu ke modelnya sendiri agar tidak "tercemar" |
| Shared Kernel | Dua context berbagi sebagian kecil model — dipakai hemat karena mengikat keduanya |
| Published Language | Kontrak bersama yang terdokumentasi, misal skema event atau OpenAPI |
text
[Katalog] --(Published Language: ProductPublished)--> [Pesanan]
[Pesanan] --(event OrderPlaced)--> [Pembayaran], [Inventaris], [Notifikasi]
[Pembayaran] --(ACL)--> [Payment gateway pihak ketiga]Satu bounded context adalah kandidat satu microservice. Tidak wajib satu banding satu — satu context bisa tetap satu modul di modular monolith — tapi satu service sebaiknya tidak memuat potongan dari beberapa context yang bahasanya bertentangan.
Entity vs value object
Entity punya identitas yang bertahan meski atributnya berubah. Pelanggan yang ganti alamat tetap pelanggan yang sama karena customerId-nya sama. Value object tidak punya identitas; ia didefinisikan sepenuhnya oleh nilainya, immutable, dan dua value object dengan nilai sama dianggap sama — misalnya Money, Address, atau DateRange.
| Entity | Value object | |
|---|---|---|
| Identitas | Ada (ID) | Tidak ada |
| Kesamaan | Berdasarkan ID | Berdasarkan seluruh nilai |
| Mutabilitas | Bisa berubah lewat method bisnis | Immutable; "ubah" = buat objek baru |
| Contoh | Order, Customer, Shipment | Money, Address, Sku, Quantity |
Di Java, record adalah wadah alami untuk value object: immutable, equals/hashCode dari komponen, dan validasi bisa ditaruh di compact constructor.
Aggregate dan aggregate root
Aggregate adalah sekelompok entity dan value object yang harus konsisten bersama dan diperlakukan sebagai satu unit perubahan. Satu entity di dalamnya menjadi aggregate root — satu-satunya pintu masuk. Kode luar tidak boleh mengubah bagian dalam aggregate secara langsung, sehingga root selalu bisa menjaga invariant (aturan yang harus selalu benar), misalnya "pesanan yang sudah dipesan tidak bisa ditambah item" atau "total = jumlah subtotal".
Aturan praktis aggregate:
- Satu transaksi database mengubah satu aggregate.
- Aggregate lain dirujuk lewat ID, bukan referensi objek (
customerId, bukanCustomer customer). - Buat aggregate sekecil mungkin yang masih menjaga invariant-nya.
- Konsistensi antar-aggregate dicapai lewat domain event (eventual consistency).
Contoh lengkap berikut memodelkan Money sebagai value object, Order sebagai aggregate root, dan OrderPlaced sebagai domain event:
java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class OrderAggregateDemo {
// Value object: tidak punya identitas, dibandingkan dari nilainya, immutable.
record Money(BigDecimal amount, String currency) {
Money {
if (amount.signum() < 0) {
throw new IllegalArgumentException("Nominal tidak boleh negatif: " + amount);
}
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Mata uang berbeda: " + currency + " vs " + other.currency);
}
return new Money(amount.add(other.amount), currency);
}
Money times(int qty) {
return new Money(amount.multiply(BigDecimal.valueOf(qty)), currency);
}
}
record OrderLine(String sku, int quantity, Money unitPrice) {
Money subtotal() {
return unitPrice.times(quantity);
}
}
// Domain event: fakta yang sudah terjadi, dinamai dalam bentuk lampau.
record OrderPlaced(UUID orderId, Money total) {}
enum Status { DRAFT, PLACED }
// Aggregate root: satu-satunya pintu masuk untuk mengubah isi pesanan.
static final class Order {
private final UUID id = UUID.randomUUID();
private final List<OrderLine> lines = new ArrayList<>();
private final List<Object> pendingEvents = new ArrayList<>();
private Status status = Status.DRAFT;
void addLine(String sku, int quantity, Money unitPrice) {
if (status != Status.DRAFT) {
throw new IllegalStateException("Pesanan sudah " + status + ", tidak bisa diubah");
}
if (quantity <= 0) {
throw new IllegalArgumentException("Jumlah harus > 0");
}
lines.add(new OrderLine(sku, quantity, unitPrice));
}
void place() {
if (lines.isEmpty()) {
throw new IllegalStateException("Pesanan kosong tidak bisa dipesan");
}
status = Status.PLACED;
pendingEvents.add(new OrderPlaced(id, total()));
}
Money total() {
Money sum = new Money(BigDecimal.ZERO, "IDR");
for (OrderLine line : lines) {
sum = sum.plus(line.subtotal());
}
return sum;
}
List<OrderLine> lines() {
return List.copyOf(lines); // salinan read-only: luar tidak bisa menembus invariant
}
List<Object> pullEvents() {
List<Object> copy = List.copyOf(pendingEvents);
pendingEvents.clear();
return copy;
}
}
public static void main(String[] args) {
Money a = new Money(new BigDecimal("15000"), "IDR");
Money b = new Money(new BigDecimal("15000"), "IDR");
System.out.println("Value object sama? " + a.equals(b));
Order order = new Order();
order.addLine("KOPI-250G", 2, new Money(new BigDecimal("45000"), "IDR"));
order.addLine("GULA-1KG", 1, a);
order.place();
System.out.println("Total: " + order.total().amount() + " " + order.total().currency());
System.out.println("Event: " + order.pullEvents().size() + " x OrderPlaced");
try {
order.addLine("SUSU-1L", 1, a);
} catch (IllegalStateException e) {
System.out.println("Ditolak: " + e.getMessage());
}
try {
order.lines().add(new OrderLine("HACK", 99, a));
} catch (UnsupportedOperationException e) {
System.out.println("Ubah dari luar: " + e.getClass().getSimpleName());
}
}
}text
Value object sama? true
Total: 105000 IDR
Event: 1 x OrderPlaced
Ditolak: Pesanan sudah PLACED, tidak bisa diubah
Ubah dari luar: UnsupportedOperationExceptionPerhatikan bahwa tidak ada setter. Satu-satunya cara mengubah pesanan adalah method bisnis (addLine, place), dan lines() mengembalikan salinan read-only — upaya mengubahnya dari luar langsung ditolak.
Domain event
Domain event adalah catatan tentang sesuatu yang sudah terjadi dan penting bagi bisnis: OrderPlaced, PaymentCaptured, StockReserved, ShipmentDispatched. Namanya selalu bentuk lampau dan memakai ubiquitous language. Bedakan dengan command (PlaceOrder, CapturePayment) yang berupa permintaan dan boleh ditolak — event tidak bisa ditolak, karena faktanya sudah terjadi.
Domain event adalah lem antar-context: Pesanan tidak perlu tahu siapa saja yang peduli pada pesanan baru; ia cukup menerbitkan OrderPlaced, lalu Pembayaran, Inventaris, dan Notifikasi bereaksi sendiri. Detail pengirimannya (Kafka, outbox) dibahas di bab event-driven dan saga.
Event storming sekilas
Event storming adalah workshop kolaboratif untuk menemukan domain event dan batas context. Peserta (developer + orang bisnis) menempel sticky note berwarna di dinding panjang:
- Tulis semua domain event (oranye) dalam bentuk lampau, urutkan sesuai waktu.
- Tambahkan command (biru) yang memicu event dan aktor yang memberi command.
- Tandai aturan/policy ("bila pembayaran berhasil, maka cadangkan stok") dan sistem eksternal.
- Kelompokkan event yang memakai bahasa sama dan berubah bersama — kelompok itu kandidat bounded context.
Hasilnya bukan dokumen sempurna, melainkan pemahaman bersama tentang alur bisnis dan garis batas yang masuk akal.
Contoh di dunia backend
Dekomposisi e-commerce berdasarkan bounded context:
| Service | Tanggung jawab | Aggregate utama | Event yang diterbitkan | Event yang didengarkan |
|---|---|---|---|---|
| Katalog | Data produk & harga tampil | Product | ProductPublished, PriceChanged | — |
| Pesanan | Siklus hidup pesanan | Order | OrderPlaced, OrderCancelled | PaymentCaptured, StockReserved |
| Pembayaran | Tagihan & integrasi payment gateway | Payment | PaymentCaptured, PaymentFailed | OrderPlaced |
| Inventaris | Stok & reservasi | StockItem | StockReserved, StockRejected | OrderPlaced, OrderCancelled |
| Pengiriman | Pembuatan & pelacakan kiriman | Shipment | ShipmentDispatched | PaymentCaptured |
| Notifikasi | Email/WA ke pelanggan | — (tanpa aggregate kompleks) | — | hampir semua event di atas |
Setiap service memiliki datanya sendiri. Pesanan menyimpan salinan harga saat dibeli di OrderLine, bukan merujuk ke tabel harga katalog — harga katalog boleh berubah besok tanpa mengubah pesanan kemarin. Di context Pembayaran, respons payment gateway diterjemahkan oleh anti-corruption layer agar istilah vendor tidak bocor ke model domain:
java
// Potongan — anti-corruption layer di context Pembayaran
public class GatewayPaymentTranslator {
public PaymentResult toDomain(GatewayChargeResponse response) {
return switch (response.transactionStatus()) {
case "settlement", "capture" -> PaymentResult.captured(response.transactionId());
case "deny", "cancel", "expire" -> PaymentResult.failed(response.statusMessage());
default -> PaymentResult.pending(response.transactionId());
};
}
}Bandingkan dengan anti-pattern microservice per tabel: order-service untuk tabel orders, order-item-service untuk tabel order_items, customer-service hanya sebagai CRUD tabel customers. Membuat satu pesanan lalu butuh beberapa panggilan HTTP yang tidak bisa dibungkus satu transaksi, padahal Order dan OrderItem adalah satu aggregate dengan invariant bersama. Service yang dipotong per tabel cenderung chatty, rapuh, dan tidak memiliki logika bisnis — semua aturan pindah ke "service orkestrator" yang menjadi monolith baru.
Pertanyaan interview
1. Apa itu bounded context dan bagaimana hubungannya dengan microservice?
Bounded context adalah batas eksplisit tempat satu model domain dan bahasanya berlaku konsisten; istilah yang sama boleh punya arti berbeda di context lain. Bounded context adalah kandidat alami untuk batas microservice karena model dan datanya bisa berubah mandiri. Namun tidak wajib satu banding satu — satu context bisa tetap menjadi modul di modular monolith, dan yang penting adalah satu service tidak mencampur model dari context yang bahasanya bertentangan.
2. Apa perbedaan entity dan value object? Berikan contoh.
Entity punya identitas yang tetap walau atributnya berubah, misalnya Order dengan orderId. Value object tidak punya identitas, immutable, dan dibandingkan dari seluruh nilainya, misalnya Money(15000, "IDR") atau Address. Di Java, value object cocok dimodelkan dengan record beserta validasi di compact constructor.
3. Kenapa satu transaksi sebaiknya hanya mengubah satu aggregate?
Aggregate adalah batas konsistensi: invariant di dalamnya dijamin oleh aggregate root dalam satu transaksi. Mengubah banyak aggregate sekaligus memperbesar lock dan contention, serta menjadi mustahil ketika aggregate itu berada di service berbeda. Konsistensi antar-aggregate dicapai dengan domain event dan eventual consistency, misalnya stok dicadangkan setelah OrderPlaced diterima.
Jebakan umum
- Microservice per tabel. Memotong berdasarkan skema database, bukan kapabilitas bisnis, membelah aggregate dan menghasilkan service yang cerewet.
- Model anemik. Entity hanya berisi getter/setter, aturan bisnis tersebar di service — invariant mudah dilanggar.
- Mengekspos koleksi internal. Mengembalikan
Listyang bisa diubah membuat aggregate root bisa dilewati; kembalikan salinan read-only. - Aggregate terlalu besar.
Customeryang memuat semua pesanan dan pembayarannya membuat setiap perubahan mengunci data besar; rujuk aggregate lain lewat ID. - Satu model untuk semua context. Class
Productraksasa yang dipakai semua tim mengikat semua service ke satu skema. - Event dinamai seperti perintah (
UpdateStock) — event adalah fakta lampau (StockReserved).
Ringkasan
- Ubiquitous language menyatukan bahasa bisnis dan kode; nama method mencerminkan aksi bisnis.
- Bounded context membatasi tempat satu model berlaku; context map menggambarkan hubungan antar-context.
- Entity punya identitas, value object didefinisikan nilainya —
recordcocok untuk value object. - Aggregate root menjaga invariant; satu transaksi mengubah satu aggregate, aggregate lain dirujuk lewat ID.
- Domain event (bentuk lampau) menghubungkan context secara longgar; event storming membantu menemukannya.
- Pecah service per bounded context/kapabilitas bisnis, bukan per tabel.