Menguasai event-driven dengan Kafka: topic, partition, consumer group, offset, dan at-least-once; skema event standar tokokita; transactional outbox agar event tidak hilang di sela transaksi database; dan consumer idempoten untuk at-least-once yang tetap diproses sekali

Setelah episode 11 menuntaskan sisi sinkron, kini kita benar-benar mendalami event-driven — tulang punggung decoupling tokokita. Kafka bukan sekadar "queue"; ia adalah log terdistribusi dengan model topik-partisi-offset yang berbeda dari antrian biasa. Kesalahan memahaminya akan berujung pada event hilang atau event ganda yang tidak diantisipasi.
Mengapa episode ini krusial? Karena pola outbox di sini adalah salah satu pola yang paling sering ditanyakan di wawancara arsitektur dan paling banyak disalahimplementasikan di produksi. Event yang hilang di antara commit database dan publish ke Kafka adalah bug kelas berat yang diam-diam merusak bisnis.
order-events) tempat pesan (event) ditulis.partition 0: [order:1] [order:3] [order:5]
partition 1: [order:2] [order:4]
partition 2: [order:6]Ordering penting: pesan untuk order_id yang sama harus di partition yang sama (pakai key = order_id), agar event order tidak diproses acak.
Kafka menjamin at-least-once (sekali atau lebih) bila consumer commit offset setelah sukses diproses. Implikasinya ganda:
Respons yang benar bukan menuntut exactly-once transport, melainkan membuat consumer idempoten — diterangkan di bawah.
Semua event memakai amplop (envelope) seragam agar consumer mana pun bisa membaca minimal metadata:
{
"id": "uuid", // global event id — untuk dedup
"type": "order.confirmed", // nama event (dot notation)
"timestamp": "ISO-8601",
"data": { ... }, // payload domain
"occurredAt": "ISO-8601" // kapan terjadi di dunia nyata (beda dari process time)
}Skema ini didefinisikan zod di shared-types — sudah kita gunakan sejak episode 4.
user-events → user.registered
product-events → product.created, product.updated, product.reserved, product.released
order-events → order.created, order.confirmed, order.cancelled, order.shipped
payment-events → payment.succeeded, payment.failedPembagian topik per-domain (bukan per-tipe-event) memudahkan pemantauan lag per domain dan menjaga konsumen tetap fokus.
Saat order-service menulis order di database lalu (terpisah) publish order.confirmed ke Kafka, ada celah: jika publish gagal setelah commit, event alami hilang — payment-service tidak pernah tahu order harus dibayar. Sebaliknya jika publish sukses tapi commit gagal, event terkirim untuk order yang tidak ada (ghost).
Tulis event ke tabel outbox dalam transaksi DB yang sama dengan perubahan bisnis:
create table outbox_events (
id bigserial primary key,
event_id uuid not null unique,
event_type text not null,
payload jsonb not null,
status text not null default 'pending', -- pending / published
created_at timestamptz not null default now()
);await using q = db.begin()
const [order] = await q.query('insert into orders (...) values (...) returning *', [...])
await q.query(
`insert into outbox_events (event_id, event_type, payload)
values ($1, $2, $3)`,
[randomUUID(), 'order.confirmed', JSON.stringify(envelope(order))]
)
await q.commit() // atomik: order + event lahir bersamaSetelah itu, sebuah relay worker membaca baris pending dan mem-publish ke Kafka:
export async function relayOutbox() {
const rows = await db.query(
`select * from outbox_events
where status = 'pending' order by id
limit 100`
)
for (const row of rows) {
await produce(topicsFor(row.event_type), [
{ key: row.event_id, value: row.payload },
])
await db.query('update outbox_events set status = $1 where id = $2', ['published', row.id])
}
}
// dijalankan interval misal tiap 500ms; idempoten & bisa di-restore kapan punManfaatnya luar biasa: commit DB dan antrean event sekarang atomik — tidak ada lagi "order tersimpan tapi event-nya hilang". Relay bisa dire-run; event yang sudah published dilewati.
Important
Kunci outbox: satu transaksi database mencakup perubahan bisnis DAN penulisan event. Jika keduanya di database yang berbeda (misal bisnis di PostgreSQL, outbox di Redis), atomicity kembali hilang. Itulah kenapa outbox selama ini hidup di DB utama tiap service — bukan di broker. Contoh kedua relay: di notification-service episode 9 kita sudah menerapkan pola yang sama untuk email.
Karena at-least-once, konsumen wajib menanggung kemungkinan duplikat. Dua lapis pertahanan:
INSERT ... ON CONFLICT DO NOTHING, UPDATE ... WHERE status = $1, atau UNIQUE per order_id.Contoh payment-service yang menerima order.confirmed dua kali:
insert into payments (order_id, amount, status)
values ($1, $2, 'PENDING')
on conflict (order_id) do nothing;
-- jika sudah ada, tidak membuat pembayaran gandaLapis 1 mencegah kerja dobel; lapis 2 menjamin efek akhir tetap benar bahkan jika lapis 1 gagal (misal Redis hilang). Keduanya bukan "atau", melainkan "dan".
Episode 12 tuntas membedah event-driven:
id, type, timestamp, data, occurredAt.Di episode 13 selanjutnya, kita membangun saga pattern — solusi untuk transaksi lintas layanan yang tidak mungkin ACID: saga orchestration vs choreography, compensating transactions saat payment gagal, dan implementasi state per step di order-service. Sampai jumpa di episode 13!