Membangun notification-service sebagai konsumen event lintas topik: mengirim email (Resend/SMTP) dan notifikasi, deduplication by event id, keandalan pengiriman dengan outbox pattern, dan testing email lokal menggunakan Mailpit

Setelah payment-service (episode 8) menuntaskan transaksi lintas event, kini giliran layanan yang paling "rewel" soal keandalan: notification-service. Layanan ini menerima banyak jenis event dari layanan lain dan mengubahnya menjadi komunikasi ke user — email selamat datang, konfirmasi order, bukti pembayaran, kabar pengiriman.
Mengapa notification-service jadi pelajaran penting? Karena ia adalah contoh sempurna asinkron yang mengharuskan at-least-once di-handle dengan benar. Kafka mengirim event minimal satu kali — bisa dua, tiga, atau sepuluh kali saat ada retry. Kalau notification-service bodoh, satu order sukses bisa menghasilkan 5 email pengiriman. Deduplication wajib, dan pengiriman yang gagal harus bisa dipulihkan — itulah fungsi outbox.
Konsumsi event dari beberapa topik dan kirim pesan:
| Event | Tindakan |
|---|---|
user.registered | Email selamat datang |
order.confirmed | Email "order diterima" |
payment.succeeded | Email bukti pembayaran |
order.shipped | Email tracking pengiriman |
order.cancelled | Email pemberitahuan pembatalan |
TypeScript + Bun (worker konsumen)
Kafka (konsumen: user-events, order-events, payment-events)
PostgreSQL (outbox_emails, notifications)
Redis (deduplication event id + rate limit)
Resend / SMTP (pengiriman email); Mailpit di devNotification-service bergabung ke beberapa consumer group agar skala horizontal aman — satu group memastikan satu event hanya diproses sekali oleh satu instance.
user-events ─┐
order-events ─┼→ notification-service (group: notification-service)
payment-events ─┘Topik adalah "channel"; consumer group adalah "anak tangga" yang menentukan lag. Worker di-scale dengan consumer lag (yang dibahas penuh di episode 21 dengan KEDA). Semakin besar lag → semakin banyak instance yang dibutuhkan.
Karena Kafka at-least-once, event bisa terkirim dua kali. Simpan event_id yang sudah diproses di Redis (dengan TTL, misal 24 jam):
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
export async function isProcessed(eventId: string): Promise<boolean> {
const ok = await redis.set(`processed:notif:${eventId}`, '1', { NX: true, EX: 86400 })
return ok === null // sudah ada → true (event pernah diproses)
}
export async function handle(event: Envelope) {
if (await isProcessed(event.id)) return // lewati duplikat
await enqueueEmail(render(event))
}Kunci NX (set if not exists) membuat cek+simpan atomic — dua konsumen paralel dengan event sama tidak bisa sama-sama lolos.
Tip
Dedup ber-TTL itu kompromi: jika TTL kedaluwarsa, event lama yang sangat terlambat bisa terkirim lagi — skenario langka tapi nyata. Untuk jaminan keras (uang!), simpan processed id juga di PostgreSQL dengan unique constraint tanpa TTL. Aturan praktis: dedup Redis cukup untuk notifikasi; dedup DB wajib untuk domain kritis seperti payment dan inventory.
Ini tema besar (diperdalam di episode 12). Notifikasi adalah contoh nyata kenapa outbox dibutuhkan: kita tidak boleh "kehilangan" email selamat datang hanya karena crash tepat setelah konsumsi event sebelum berhasil memanggil SMTP.
Alur outbox di notification-service:
outbox_emails dengan status pending (dalam transaksi DB yang sama dengan dedup jika dipakai).sent; gagal → tandai failed + hitung retry (dengan backoff), bisa di-restore kapan pun.create table outbox_emails (
id bigserial primary key,
subject text not null,
to_email text not null,
body text not null,
status text not null default 'pending', -- pending / sent / failed
attempts int not null default 0,
created_at timestamptz not null default now()
);Kenapa tidak kirim email langsung di konsumen? Karena konsumen Kafka mem-commit offset setelah handler selesai. Jika SMTP sukses tapi proses crash sebelum commit, event diproses ulang → email ganda. Jika proses crash sebelum SMTP dipanggil, offset tak ter-commit → event diproses ulang → email tepat sekali (untung). Outbox membuat komitmen persisten di DB lokal, memutus ketergantungan antara offset Kafka dan efek samping eksternal.
Selama development, jangan kirim email sungguhan. Pakai Mailpit — SMTP catcher lokal dengan UI web untuk inspeksi:
mailpit:
image: axllent/mailpit:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # UI web inboxService mengirim ke smtp://mailpit:1025; semua email mendarat di http://localhost:8025 untuk inspeksi — memudahkan debugging template dan alur tanpa risiko email bocor.
Setelah semua service hidup, uji alur notifikasi:
# register user → buka http://localhost:8025, cek email selamat datang
curl -s -X POST localhost:4000/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"budi@tokokita.id","password":"rahasia123","name":"Budi"}'
docker compose logs -f notification-service --tail 20Episode 9 membangun notification-service yang andal:
user-events, order-events, payment-events) + consumer group.NX, ber-TTL) — at-least-once tidak menghasilkan email ganda.Di episode 10 selanjutnya, kita akan menyatukan semuanya — membangun api-gateway (routing, validasi JWT, rate-limit, CORS, agregasi) dan web-app React, lalu menjalankan alur end-to-end pertama: register → login → browse → add to cart → checkout → bayar → status PAID → email sampai. Sampai jumpa di episode 10!