Membangun sistem yang reaktif: arsitektur event-driven dengan Kafka, event sourcing yang mencatat sejarah sebagai sumber kebenaran, dan pola CQRS yang memisahkan jalan tulis dari jalan baca

Di episode 10 kalian memakai queue untuk pekerjaan asinkron sederhana. Episode 22 menaikkan level: event-driven architecture dan streaming — paradigma di mana sistem bukan sekadar menyimpan keadaan terakhir, melainkan mengalirkan dan merekam peristiwa yang terjadi. Ini fondasi sistem modern: logistik, finansial, platform data, dan AI pipeline.
Mengapa penting? Karena pola "tanya database, jawab status" tidak cukup untuk sistem yang kompleks — banyak layanan butuh tahu apa yang terjadi, bukan hanya keadaan sekarang. Event-driven memungkinkan banyak konsumen bereaksi terhadap satu peristiwa, merekonstruksi keadaan dari riwayat, dan menyelaraskan sistem yang sebelumnya terpisah.
Prinsipnya: komponen tidak memanggil satu sama lain secara langsung, melainkan menerbitkan event yang dikonsumsi siapa saja yang peduli.
Keuntungan dibanding pemanggilan sinkron (episode 12):
Kafka (dari episode 10) adalah event bus standar. Konsep intinya: topics yang menampung event dalam partitions, dan consumer group yang melacak offset.
import { Kafka } from "kafkajs"
const kafka = new Kafka({ brokers: process.env.KAFKA_BROKERS.split(",") })
const producer = kafka.producer()
export async function publishOrderPlaced(order: Order) {
await producer.send({
topic: "order-events",
messages: [{
// key menentukan partition → urutan per order terjaga
key: String(order.id),
value: JSON.stringify({
type: "order.placed",
orderId: order.id,
userId: order.userId,
items: order.items,
total: order.total,
at: new Date().toISOString(),
}),
}],
})
}Event dengan key yang sama (misal orderId) selalu masuk partition yang sama — sehingga urutan event per order terjaga. Ini penting: order.cancelled tidak boleh diproses sebelum order.placed.
order.placed, payment.succeeded. Konsumen tidak bisa "menolak".Pisahkan keduanya: event untuk menyebarkan fakta, command/queue untuk tugas yang harus dikerjakan.
Event sourcing mengubah cara data disimpan: alih-alih hanya menyimpan keadaan terakhir, simpan seluruh riwayat event sebagai sumber kebenaran. Keadaan saat ini hanyalah proyeksi dari riwayat.
order 87 → { status: "shipped", total: 300000 } # keadaan terakhir sajaorder 87:
1. order.placed → total 300000
2. payment.succeeded
3. order.shipped
4. order.deliveredKeuntungan: sejarah penuh — audit (episode 19), analisis, dan time travel (rekonstruksi keadaan di masa lalu) tersedia gratis. Setiap event tidak bisa diubah (immutable).
export function projectOrder(events: OrderEvent[]): OrderState {
return events.reduce<OrderState>((state, e) => {
switch (e.type) {
case "order.placed":
return { ...state, id: e.orderId, status: "pending", total: e.total }
case "payment.succeeded":
return { ...state, status: "paid" }
case "order.shipped":
return { ...state, status: "shipped" }
case "order.cancelled":
return { ...state, status: "cancelled" }
default:
return state
}
}, { status: "unknown" })
}Trade-off event sourcing: kompleksitas penyimpanan (event store), dan state saat ini baru bisa dibaca setelah projection berjalan. Karena itu event sourcing hampir selalu dipasangkan dengan CQRS.
Note
Event sourcing tidak menggantikan database biasa. Ada dua gudang: event store (sumber kebenaran, append-only) dan read model (database normal berisi state terproyeksi untuk dibaca cepat). Jangan memaksa semua data jadi event — mulai dari domain yang butuh sejarah & audit (order, payment), bukan yang sederhana.
CQRS memisahkan jalan tulis dari jalan baca:
Mengapa terpisah? Karena kebutuhan tulis dan baca sering bertolak belakang. Tulis butuh konsistensi dan validasi ketat; baca butuh kecepatan dan bentuk yang nyaman. Read model bisa berbentuk persis yang dibutuhkan UI — pre-joined, pre-aggregated, tanpa join mahal.
Read model diperbarui oleh projector yang mengonsumsi event:
consumer.subscribe({ topic: "order-events" })
await consumer.run({
eachMessage: async ({ message }) => {
const event = JSON.parse(message.value.toString())
if (event.type === "order.placed") {
await db.query(
`INSERT INTO order_read (id, user_id, status, total)
VALUES ($1, $2, 'pending', $3)
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status`,
[event.orderId, event.userId, event.total],
)
}
},
})Warning
Event-driven berarti eventual consistency: setelah publish, read model bisa tertinggal beberapa saat. Endpoint "baca order" mungkin belum mencerminkan event yang baru saja terjadi. Untuk domain yang wajib baca-segera-setelah-tulis (misal konfirmasi checkout), pertimbangkan kombinasi tulis langsung + event, atau komunikasikan keterlambatan ini dengan jelas.
Terapkan alur lengkap:
POST /v1/orders → simpan event order.placed ke Kafka.order_read).docker exec kafka-lab kafka-topics.sh --list --bootstrap-server localhost:9092
docker exec kafka-lab kafka-console-consumer.sh \
--topic order-events --from-beginning --bootstrap-server localhost:9092Konsumen baru bisa dibaca dari awal (from-beginning) — inilah daya replay event-driven.
Menerbitkan "to do X" sebagai event mengikat semua konsumen pada interpretasi yang sama. Event = fakta (order.placed); tindakan = keputusan konsumen.
Event yang berubah bentuk merusak semua konsumen diam-diam. Schema Registry (AVRO/JSON Schema) + contract testing (episode 8) wajib untuk event.
Transfer saldo pakai event-driven → saldo bisa salah baca. Uang tetap butuh transaction (episode 5).
CRUD sederhana jadi ribet tanpa manfaat. Pilih domain yang butuh sejarah.
Episode 22 membangun sistem reaktif: arsitektur event-driven dengan Kafka, event sourcing yang menjadikan riwayat sebagai sumber kebenaran, dan CQRS yang memisahkan jalan tulis dan baca.
Inti yang harus dibawa pulang:
Di episode 23 selanjutnya kita akan membawa backend ke era 2026: AI & LLM integration — mengintegrasikan LLM API, membangun RAG backend, dan orchestration agent. Sampai jumpa di episode 23!