Tampilan
Kenapa penting
Di bab sebelumnya service saling memanggil secara sinkron lewat REST dan gRPC: pemanggil menunggu jawaban, dan kalau yang dipanggil mati, pemanggil ikut gagal. Untuk banyak alur bisnis itu tidak perlu. Saat pesanan dibayar, service Gudang perlu menyiapkan barang, service Notifikasi perlu mengirim email, dan service Loyalty perlu menambah poin — tapi service Pembayaran tidak perlu menunggu mereka semua. Arsitektur event-driven membalik arah ketergantungan: service menerbitkan fakta ("pesanan ini sudah dibayar"), dan siapa pun yang peduli bereaksi sendiri. Hasilnya service lebih lepas (loosely coupled) dan lebih tahan gangguan, dengan harga berupa konsistensi eventual, debugging yang lebih sulit, dan kewajiban menangani event dobel serta terlambat. Hampir semua sistem e-commerce, pembayaran, dan logistik skala menengah ke atas memakai pola ini.
Konsep inti
Tiga cara memakai event
Kata "event" dipakai untuk tiga pola yang berbeda. Mencampurnya adalah sumber banyak desain yang membingungkan.
| Pola | Isi event | Consumer butuh data lain? | Contoh |
|---|---|---|---|
| Event notification | Hanya penanda + ID | Ya, memanggil balik penerbit | OrderPaid { orderId } |
| Event-carried state transfer | Data lengkap yang dibutuhkan consumer | Tidak, cukup dari event | OrderPaid { orderId, items, total, ... } |
| Event sourcing | Event adalah sumber kebenaran state | State dibangun ulang dari event | Saldo dompet = jumlah semua transaksi |
Event notification ringan dan skemanya jarang berubah, tetapi consumer harus memanggil balik service penerbit (GET /orders/{id}) — kopling sinkron muncul lagi dan penerbit menanggung beban. Event-carried state transfer membuat consumer mandiri: ia menyimpan salinan data yang ia perlukan, sehingga tetap bisa bekerja walau penerbit mati. Biayanya: event lebih besar dan skemanya lebih sering berevolusi. Event sourcing adalah pola penyimpanan, bukan pola integrasi — state aggregate tidak disimpan sebagai baris yang di-update, melainkan sebagai deretan event yang di-replay. Ini kuat untuk audit, tetapi rumit; jangan memakainya hanya karena kamu sudah memakai Kafka.
Koreografi vs orkestrasi
Alur "pesanan dibuat → stok dicadangkan → pembayaran ditagih → pesanan dikonfirmasi" bisa dikoordinasikan dengan dua cara.
| Aspek | Koreografi | Orkestrasi |
|---|---|---|
| Pengendali alur | Tidak ada; tiap service bereaksi terhadap event | Satu orchestrator mengirim perintah |
| Kopling | Sangat rendah, service cukup tahu nama event | Orchestrator tahu semua langkah |
| Melihat alur utuh | Sulit; tersebar di banyak service | Mudah; ada di satu kode/state machine |
| Menambah reaksi baru | Cukup buat consumer baru | Ubah orchestrator |
| Penanganan gagal | Tiap service harus menerbitkan event gagal | Terpusat, termasuk langkah kompensasi |
| Cocok untuk | Alur pendek, reaksi independen (notifikasi, poin) | Alur panjang dengan banyak langkah dan kompensasi |
Aturan praktis: mulai dengan koreografi untuk reaksi yang benar-benar independen. Begitu kamu perlu menjawab pertanyaan "pesanan ini macet di langkah mana?" dan jawabannya harus dicari di lima log berbeda, itu tanda alur tersebut butuh orkestrasi. Bab saga berikutnya membahas keduanya lebih dalam.
Mendesain event
Event yang baik adalah fakta domain yang sudah terjadi, bukan perintah terselubung.
- Nama bentuk lampau dari sudut pandang domain:
OrderPlaced,OrderPaid,StockReserved. HindariSendInvoice— itu perintah, dan menyiratkan penerbit tahu siapa yang harus bereaksi. - Metadata standar:
eventIdunik (untuk deduplikasi),occurredAt,aggregateId,versionatau nomor urut per aggregate, danschemaVersion. - Payload secukupnya: data yang dibutuhkan consumer yang wajar, bukan seluruh entity database.
json
{
"eventId": "8f0c2b7e-4a1d-4c0e-9a51-1b2f9d3e7c10",
"eventType": "OrderPaid",
"schemaVersion": 2,
"occurredAt": "2026-10-23T07:15:42Z",
"aggregateId": "ORD-1001",
"aggregateVersion": 3,
"payload": {
"customerId": "CUST-77",
"totalAmount": "1250000.00",
"currency": "IDR",
"paymentMethod": "VIRTUAL_ACCOUNT"
}
}Di Java, record cocok untuk merepresentasikan event karena immutable:
java
// Potongan — tipe event di service Pembayaran
public record OrderPaid(
UUID eventId,
Instant occurredAt,
String orderId,
long aggregateVersion,
String customerId,
BigDecimal totalAmount,
String currency) {}Evolusi skema. Event yang sudah terbit dibaca oleh consumer yang tidak kamu kendalikan, dan di Kafka event lama masih tersimpan berhari-hari. Aturan aman: tambah field opsional, jangan hapus atau ganti makna field. Consumer harus mengabaikan field yang tidak dikenal. Bila perubahan benar-benar tidak kompatibel, terbitkan event baru (OrderPaidV2) atau topic baru dan jalankan keduanya selama masa transisi. Untuk tim besar, schema registry (misalnya untuk skema Avro, Protobuf, atau JSON Schema) menyimpan skema setiap event dan menolak perubahan yang melanggar aturan kompatibilitas sebelum producer sempat menerbitkannya.
Kafka sebagai backbone, ordering, dan partisi
Kafka cocok menjadi tulang punggung event karena event disimpan sebagai log yang tahan lama: consumer baru bisa membaca dari awal, consumer yang mati bisa melanjutkan dari offset terakhir. Yang perlu selalu diingat: Kafka hanya menjamin urutan di dalam satu partisi, bukan di seluruh topic. Maka kirim event dengan key = ID aggregate (misalnya orderId). Partitioner meng-hash key, sehingga semua event satu pesanan selalu jatuh ke partisi yang sama dan dibaca berurutan, sementara pesanan berbeda tersebar ke partisi lain dan bisa diproses paralel.
java
// Potongan — Spring Kafka, key = orderId agar urutan per pesanan terjaga
kafkaTemplate.send("orders.events", event.orderId(), event);Jangan memilih key yang terlalu kasar (misalnya merchantId untuk merchant raksasa) karena satu partisi akan kebanjiran — disebut hot partition. Menambah jumlah partisi di kemudian hari juga mengubah pemetaan key ke partisi, jadi tentukan jumlahnya dengan cukup longgar sejak awal.
Event dobel dan terlambat
Di sistem terdistribusi, "tepat satu kali" hampir selalu berarti at-least-once + consumer idempoten. Producer bisa mengirim ulang setelah timeout, relay outbox bisa crash sebelum mencatat bahwa event sudah terkirim, dan consumer bisa memproses event lalu mati sebelum commit offset. Akibatnya event bisa datang dua kali. Event juga bisa datang terlambat atau tidak berurutan — misalnya dari dua topic berbeda, atau karena retry.
Pertahanannya ada dua: catat eventId yang sudah diproses (deduplikasi), dan bandingkan aggregateVersion dengan versi terakhir yang kamu simpan (tolak event basi). Simulasi berikut menunjukkan logikanya dalam Java murni:
java
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class OrderEventConsumer {
record OrderEvent(String eventId, String orderId, long version, String type) {}
private final Set<String> processedEventIds = new HashSet<>();
private final Map<String, Long> lastVersionByOrder = new HashMap<>();
private final Map<String, String> statusByOrder = new HashMap<>();
void handle(OrderEvent event) {
if (!processedEventIds.add(event.eventId())) {
System.out.println("SKIP dobel : " + event.eventId());
return;
}
long last = lastVersionByOrder.getOrDefault(event.orderId(), 0L);
if (event.version() <= last) {
System.out.println("SKIP basi : " + event.eventId() + " (v" + event.version() + " <= v" + last + ")");
return;
}
lastVersionByOrder.put(event.orderId(), event.version());
statusByOrder.put(event.orderId(), event.type());
System.out.println("PROSES : " + event.eventId() + " -> " + event.orderId() + " = " + event.type());
}
static int partitionFor(String key, int partitions) {
return Math.floorMod(key.hashCode(), partitions);
}
public static void main(String[] args) {
System.out.println("ORD-1 -> partisi " + partitionFor("ORD-1", 3));
System.out.println("ORD-1 -> partisi " + partitionFor("ORD-1", 3));
System.out.println("ORD-2 -> partisi " + partitionFor("ORD-2", 3));
var consumer = new OrderEventConsumer();
List<OrderEvent> received = List.of(
new OrderEvent("e1", "ORD-1", 1, "OrderPlaced"),
new OrderEvent("e2", "ORD-1", 2, "OrderPaid"),
new OrderEvent("e2", "ORD-1", 2, "OrderPaid"),
new OrderEvent("e4", "ORD-1", 4, "OrderShipped"),
new OrderEvent("e3", "ORD-1", 3, "OrderPacked"));
received.forEach(consumer::handle);
System.out.println("Status akhir : " + consumer.statusByOrder);
}
}Output:
text
ORD-1 -> partisi 2
ORD-1 -> partisi 2
ORD-2 -> partisi 0
PROSES : e1 -> ORD-1 = OrderPlaced
PROSES : e2 -> ORD-1 = OrderPaid
SKIP dobel : e2
PROSES : e4 -> ORD-1 = OrderShipped
SKIP basi : e3 (v3 <= v4)
Status akhir : {ORD-1=OrderShipped}partitionFor hanya ilustrasi: partitioner bawaan Kafka memakai hash murmur2 atas byte key, bukan hashCode(), tetapi prinsipnya sama — key yang sama selalu menghasilkan partisi yang sama. Di production, Set dan Map di memori itu diganti tabel database (misalnya processed_events dengan primary key event_id) agar tahan restart, dan dicatat dalam transaksi yang sama dengan perubahan bisnisnya.
Apakah event basi selalu dibuang?
Tidak selalu. Membuang OrderPacked yang datang setelah OrderShipped aman untuk read model status. Tetapi untuk consumer yang menjumlahkan sesuatu (misalnya poin loyalty), setiap event tetap harus dihitung — cukup deduplikasi dengan eventId. Tentukan kebijakannya per consumer.
Contoh di dunia backend
Service Pesanan menerbitkan OrderPaid setelah pembayaran dikonfirmasi. Ingat masalah dual write dari M11: menyimpan ke database lalu memanggil kafkaTemplate.send(...) tidak atomik. Solusinya transactional outbox — event ditulis ke tabel outbox dalam transaksi yang sama dengan perubahan pesanan.
sql
-- Potongan — tabel outbox di database service Pesanan (PostgreSQL)
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;java
// Potongan — service Pesanan (Spring Boot 4)
@Transactional
public void markPaid(String orderId, String paymentRef) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid(paymentRef); // aggregateVersion naik
outboxRepository.save(OutboxMessage.of(
"Order", orderId, "OrderPaid",
OrderPaidPayload.from(order))); // satu transaksi dengan update pesanan
}Relay (job polling atau CDC seperti Debezium) membaca baris yang belum terkirim dan menerbitkannya ke topic orders.events dengan key aggregate_id. Di sisi lain, service Gudang, Notifikasi, dan Loyalty masing-masing punya consumer group sendiri, sehingga setiap service menerima salinan event dan memprosesnya dengan kecepatannya sendiri:
java
// Potongan — consumer di service Gudang
@KafkaListener(topics = "orders.events", groupId = "warehouse-service")
@Transactional
public void onOrderEvent(OrderEventEnvelope envelope) {
if (!"OrderPaid".equals(envelope.eventType())) {
return; // abaikan event yang tidak relevan
}
if (processedEventRepository.existsById(envelope.eventId())) {
return; // dobel: sudah diproses
}
pickingService.createPickingList(envelope.aggregateId());
processedEventRepository.save(new ProcessedEvent(envelope.eventId()));
}Kalau service Notifikasi sedang down satu jam, pesanan tetap dibayar dan Gudang tetap bekerja. Begitu Notifikasi hidup lagi, ia melanjutkan dari offset terakhir dan mengirim email yang tertunda — inilah ketahanan yang dibeli arsitektur event-driven.
Pertanyaan interview
1. Apa beda event dan command?
Command adalah permintaan agar sesuatu dilakukan (ReserveStock); ditujukan ke satu penerima yang boleh menolak. Event adalah fakta yang sudah terjadi (StockReserved); diterbitkan tanpa tahu siapa pendengarnya dan tidak bisa ditolak, hanya ditanggapi. Karena itu event dinamai bentuk lampau dan command bentuk perintah.
2. Bagaimana menjamin urutan event per pesanan di Kafka?
Kafka menjamin urutan hanya di dalam satu partisi. Kirim semua event dengan key = ID aggregate (orderId) sehingga partitioner menempatkannya di partisi yang sama, dan satu partisi dibaca oleh satu consumer dalam group. Producer juga sebaiknya idempoten agar retry tidak mengacak urutan. Consumer tetap memeriksa versi aggregate untuk berjaga terhadap event yang datang dari jalur lain.
3. Kenapa consumer harus idempoten walau sudah memakai outbox?
Outbox menjamin event tidak hilang, tetapi jaminannya at-least-once: relay bisa mengirim event lalu crash sebelum menandai published_at, sehingga event terkirim lagi setelah restart. Consumer juga bisa memproses lalu mati sebelum commit offset. Maka consumer harus menyimpan eventId yang sudah diproses, atau membuat operasinya alami idempoten.
Jebakan umum
- Menerbitkan event bernama perintah (
SendEmail,UpdateStock) — penerbit jadi tahu terlalu banyak tentang consumer, dan kopling kembali lewat pintu belakang. - Mengirim event tanpa key atau dengan key acak, lalu heran urutan
OrderPlaceddanOrderCancelledterbalik. - Memanggil
kafkaTemplate.senddi dalam method@Transactionaldan menganggapnya atomik dengan commit database — itu dual write; pakai outbox. - Menghapus atau mengganti tipe field event yang sudah terbit — consumer lama langsung rusak, dan event lama di topic tidak bisa diubah.
- Menganggap "exactly-once" Kafka berlaku sampai efek samping di database atau email — tanpa consumer idempoten, event dobel tetap menimbulkan efek dobel.
- Memasukkan seluruh entity (termasuk data sensitif) ke payload event — semua consumer dan siapa pun yang bisa membaca topic ikut melihatnya.
Ringkasan
- Event = fakta yang sudah terjadi, dinamai bentuk lampau (
OrderPaid), denganeventId, waktu, ID dan versi aggregate. - Event notification ringan tapi memicu panggilan balik; event-carried state transfer membuat consumer mandiri; event sourcing adalah pola penyimpanan, bukan sekadar integrasi.
- Koreografi cocok untuk reaksi independen; orkestrasi cocok untuk alur panjang yang butuh visibilitas dan kompensasi.
- Kafka menjamin urutan per partisi — gunakan ID aggregate sebagai key.
- Evolusi skema: tambah field opsional, jangan hapus/ubah makna; schema registry membantu menegakkannya.
- Outbox mencegah event hilang; consumer idempoten dan cek versi menangani event dobel dan terlambat.