Memecahkan problem query lintas layanan tanpa join silang: memisahkan command dan query dengan CQRS, membangun read model ter-denormalisasi yang siap dibaca cepat, menggabungkan API composition di gateway, dan menilai kapan event sourcing layak dipertimbangkan

Sampai episode 13, kita memusatkan energi pada sisi write sistem: menulis order, memvalidasi stok, memproses pembayaran. Sekarang giliran sisi yang sering diremehkan: membaca data. Di microservices, membaca itu sulit dengan cara yang tidak biasa — data terpecah di banyak layanan, dan join lintas layanan dilarang (database per service, episode 2).
Mengapa episode ini penting? Karena di e-commerce, permintaan baca jauh melebihi tulis — bisa 20:1. Kalau membaca mahal (banyak join, query berat, panggilan berantai), pengalaman user hancur walau sistem menulisnya sempurna. CQRS dan read model adalah jawaban arsitekturalnya.
Data domain tersebar. Halaman "Rekomendasi produk" butuh: produk (product-service), harga dan stok (product-service), rating (layanan review), dan mungkin riwayat pembelian (order-service). Tidak ada satu database yang menyimpan semuanya, dan kita tidak boleh JOIN lintas schema.
Masalah tambahan: model write dibentuk untuk integritas (normalisasi, constraint), sementara model read butuh kecepatan (denormalisasi, cache).
Halaman Detail Produk =
produk (product-service)
+ rating & ulasan (rating-service)
+ stok real-time (product-service/inventory)
→ join lintas layanan? Dilarang (episode 2)CQRS (Command Query Responsibility Segregation) memisahkan model yang mengubah data (command) dari model yang membaca (query):
POST /orders, PATCH /products/:id — model ternormalisasi, validasi ketat, transaksi. Satu jalan masuk write per layanan.GET /products?sort=... — model ter-denormalisasi, bebas di-cache, layanan baca bisa terpisah (read replica atau store khusus).CQRS tidak mengharuskan teknologi berbeda — cukup mindset dan endpoint berbeda. Tahap awal tokokita tidak butuh database terpisah untuk read model; cukup cache Redis + query yang dioptimalkan. Baru saat beban query melonjak, kita pisahkan read store.
Read model adalah representasi data yang dibentuk khusus untuk query. Idenya: bangun sekali lewat event, simpan dalam bentuk siap-baca, lalu layani jutaan pembaca tanpa nanya ke pemilik data.
Contoh nyata di tokokita: halaman katalog Butuh produk + rating + stok dalam satu tampilan. Kita bangun read model product_catalog yang dikonsumsi dari event dan dicache di Redis:
product.updated ──┐ ┌── read model product_catalog
rating.updated ────→ [projector] →─┤ { product_id, name, price,
stock.changed ────┘ │ rating, stock, image_key }
└──→ Redis / PG read replicaProjector (konsumen event) menjaga read model tetap segar. Ini adalah kompensasi yang adil untuk larangan join: data diduplikasi dengan sengaja, selalu di-update via event.
consume('product-events', 'catalog-projector-group', {
'product.updated': async (e) =>
db.query(
`insert into catalog (product_id, name, price, image_key)
values ($1,$2,$3,$4)
on conflict (product_id)
do update set name = excluded.name, price = excluded.price`,
[e.data.id, e.data.name, e.data.price, e.data.imageKey]
),
'rating.updated': async (e) =>
db.query('update catalog set rating = $1 where product_id = $2', [e.data.rating, e.data.id]),
})Query katalog lalu membaca catalog dengan join lokal penuh — cepat, tanpa panggilan lintas layanan. Trade-off: read model bisa tertinggal sesaat setelah write (eventual consistency) — diterima untuk katalog.
Note
Ingat perbedaan kritikal: read model boleh duplikasi data dan boleh tertinggal sesaat — karena ia hanya untuk dibaca. Write path (order, payment, inventory) harus tetap langsung dan ketat. Jangan pernah biarkan read model jadi sumber kebenaran untuk write. Harga dan stok validasi di cmd path, bukan dari read model.
Saat read model belum ada (atau untuk halaman yang menggabungkan data sangat dinamis), gunakan API composition: gateway atau layanan agregator memanggil beberapa layanan dan menggabungkan hasil — yang sudah kita buat di episode 10 untuk detail produk.
Kapan memilih mana:
| Situasi | Pilihan |
|---|---|
| Data jarang berubah, dibaca sangat sering | Read model + cache |
| Data dinamis, dibaca sesekali | API composition |
| Halaman kompleks lintas banyak layanan | BFF + composition (lalu pertimbangkan read model) |
| Konsistensi sangat ketat | Command/query langsung ke pemilik (bukan read model) |
Event sourcing menyimpan event sebagai source of truth, bukan state saat ini. State hanyalah proyeksi dari event. Keuntungan: audit lengkap, bisa replay, history utuh. Tapi biayanya nyata: model mental baru, event schema harus immutable, event store berukuran besar, dan query perlu proyeksi.
State DB: orders(id, status='PAID')
Event DB: order.created → order.confirmed → payment.succeeded → order.paid
(state PAID adalah proyeksi dari deret event ini)Untuk learner awal, event sourcing tidak disarankan sebagai default. Outbox (episode 12) sudah memberi jalur event yang solid tanpa mengorbankan kebiasaan berpikir state-based. Bahkan tim senior memakai event sourcing hanya untuk agregat tertentu (misal akun keuangan, wallet) di mana history tak ternilai — bukan untuk semua data.
Warning
Kesalahan umum: menerapkan event sourcing "biar canggih" lalu menyimpan event dan state ganda tanpa disiplin replay/upcast schema — berakhir dengan dua sumber kebenaran yang tidak sinkron. Jika ingin mencoba, mulai dari satu agregat, tetapkan event schema versioning sejak hari pertama, dan pastikan proyeksi bisa di-rebuild total dari nol (test memakai reset store).
Episode 14 menuntaskan sisi baca microservices:
Di episode 15 selanjutnya, kita membahas cache, session, dan distribusi — lapisan Redis untuk cache-aside dan invalidation via event, perbandingan distributed session vs JWT (access pendek + refresh rotate + blacklist), menerima eventual consistency untuk keranjang, serta validasi stok benar saat checkout. Sampai jumpa di episode 15!