Type-safe payload queue bukan sekadar soal format JSON yang rapi. Masalah utamanya adalah memastikan producer dan consumer berbagi kontrak data yang tegas, dapat divalidasi, dan aman saat versi payload berubah. Tanpa itu, queue mudah menjadi sumber bug produksi: worker gagal parse, field opsional diperlakukan wajib, retry mengulang efek samping, atau payload lama tidak lagi bisa diproses.
Solusi yang praktis adalah memisahkan envelope dan payload, memberi versi kontrak secara eksplisit, memvalidasi di dua sisi, dan mendesain consumer agar toleran terhadap perubahan yang direncanakan. Dengan pendekatan ini, queue tetap stabil meskipun sistem terdistribusi berevolusi secara bertahap.
Mengapa payload queue sering rusak di sistem nyata
Di banyak backend, payload queue awalnya tampak sederhana: kirim objek JSON, lalu worker memprosesnya. Masalah muncul ketika kebutuhan bertambah dan kontrak data berubah tanpa disiplin desain.
Pola kegagalan yang umum
- Payload ambigu: satu job type dipakai untuk beberapa bentuk data yang berbeda, dibedakan oleh kombinasi field yang tidak konsisten.
- Field opsional yang berbahaya: field dianggap opsional di producer, tetapi diasumsikan selalu ada oleh consumer.
- Versi kontrak tidak eksplisit: perubahan struktur dilakukan diam-diam sehingga worker lama atau baru gagal memproses payload.
- Retry tidak aman: job diulang tetapi efek samping seperti insert, charge, atau invalidasi cache terjadi berulang.
- DLQ tidak informatif: payload gagal masuk dead-letter queue tanpa metadata yang cukup untuk investigasi.
Inspirasi dari record type inference adalah ketegasan bentuk data: sistem harus tahu dengan jelas field apa yang ada, tipe nilainya, dan kapan variasi bentuk diperbolehkan. Di backend, prinsip ini diterjemahkan menjadi kontrak payload yang eksplisit, terversi, dan tervalidasi.
Prinsip desain type-safe payload queue
1. Satu job, satu kontrak yang jelas
Hindari satu tipe queue message yang menampung banyak variasi bentuk payload tanpa pembeda formal. Lebih aman jika tiap jobType memiliki schema sendiri, atau menggunakan discriminated union yang jelas.
2. Envelope dipisah dari business payload
Envelope berisi metadata transport dan operasional, sedangkan payload berisi data bisnis. Pemisahan ini penting agar retry, tracing, dan routing tidak tercampur dengan domain field.
3. Versi harus eksplisit
Jangan mengandalkan heuristik seperti “kalau field x ada berarti versi baru”. Simpan versi kontrak secara eksplisit, misalnya schemaVersion.
4. Validasi producer dan consumer
Producer wajib menolak payload yang tidak sesuai sebelum dipublish. Consumer tetap wajib memvalidasi ulang karena queue adalah batas kepercayaan antarlayanan.
5. Idempotency bukan fitur tambahan
Queue hampir selalu bersifat at-least-once delivery. Artinya duplikasi bisa terjadi. Desain payload harus mendukung eksekusi berulang tanpa merusak state.
Schema envelope yang disarankan
Berikut contoh envelope yang cukup umum untuk worker dan layanan terdistribusi:
{
"messageId": "01J0Z...",
"jobType": "email.send_welcome",
"schemaVersion": 2,
"occurredAt": "2026-06-25T10:15:00Z",
"producer": "user-service",
"traceId": "9b2f...",
"idempotencyKey": "welcome-email:user-123",
"retry": {
"attempt": 0,
"maxAttempts": 5,
"nextVisibleAt": "2026-06-25T10:15:00Z"
},
"payload": {
"userId": "user-123",
"email": "[email protected]",
"locale": "id-ID"
}
}Field envelope yang penting
- messageId: identitas unik pesan untuk tracing dan audit.
- jobType: jenis pekerjaan yang menentukan schema payload.
- schemaVersion: versi kontrak untuk deserialisasi dan migrasi.
- occurredAt: waktu event/job dibuat, berguna untuk observabilitas dan TTL.
- producer: layanan pengirim, membantu saat investigasi.
- traceId: korelasi lintas service dan log.
- idempotencyKey: kunci deduplikasi atau proteksi efek samping berulang.
- retry: metadata percobaan ulang. Sebaiknya tidak diletakkan di payload bisnis.
- payload: data domain yang diproses worker.
Catatan: hindari memasukkan objek besar atau state turunan yang mudah basi ke dalam payload. Simpan identifier penting dan data minimal yang dibutuhkan untuk proses yang deterministik.
Mencegah payload ambigu dan field opsional berbahaya
Gunakan bentuk payload yang sempit
Payload yang baik tidak mencoba menampung terlalu banyak variasi. Misalnya, daripada membuat job user.notify dengan puluhan field opsional untuk email, push, dan SMS sekaligus, lebih aman pisahkan menjadi job type spesifik seperti email.send dan sms.send.
Bedakan field yang benar-benar opsional dan field yang belum dimigrasikan
Field opsional sering menjadi sumber salah kontrak. Ada perbedaan penting antara:
- Opsional secara domain: misalnya
middleNamememang boleh tidak ada. - Opsional karena masa transisi: misalnya
localebelum diproduksi oleh semua producer.
Jika opsional hanya karena migrasi, perlakukan itu sebagai kondisi sementara dan beri fallback eksplisit di consumer, lalu hapus setelah seluruh producer diperbarui.
Hindari nilai null yang tidak bermakna
null sering dipakai untuk banyak arti: tidak ada, belum dihitung, sengaja dikosongkan, atau gagal dimuat. Lebih aman jika arti absennya field didefinisikan jelas di schema dan dokumentasi.
Contoh implementasi sederhana dengan schema validation
Berikut contoh sederhana menggunakan TypeScript untuk menunjukkan bagaimana kontrak queue bisa dijaga tetap type-safe di level kode sekaligus tervalidasi saat runtime. Contoh ini tidak bergantung pada framework tertentu.
type RetryMeta = {
attempt: number;
maxAttempts: number;
nextVisibleAt: string;
};
type Envelope<TJobType extends string, TPayload> = {
messageId: string;
jobType: TJobType;
schemaVersion: number;
occurredAt: string;
producer: string;
traceId?: string;
idempotencyKey: string;
retry: RetryMeta;
payload: TPayload;
};
type SendWelcomeEmailPayloadV2 = {
userId: string;
email: string;
locale: string;
};
type SendWelcomeEmailMessageV2 = Envelope<
"email.send_welcome",
SendWelcomeEmailPayloadV2
>;
function assertSendWelcomeEmailMessageV2(input: unknown): asserts input is SendWelcomeEmailMessageV2 {
const msg = input as Record<string, unknown>;
if (!msg || typeof msg !== "object") throw new Error("message must be object");
if (msg.jobType !== "email.send_welcome") throw new Error("invalid jobType");
if (msg.schemaVersion !== 2) throw new Error("invalid schemaVersion");
if (typeof msg.messageId !== "string") throw new Error("invalid messageId");
if (typeof msg.idempotencyKey !== "string") throw new Error("invalid idempotencyKey");
const payload = msg.payload as Record<string, unknown>;
if (!payload || typeof payload !== "object") throw new Error("payload must be object");
if (typeof payload.userId !== "string") throw new Error("invalid payload.userId");
if (typeof payload.email !== "string") throw new Error("invalid payload.email");
if (typeof payload.locale !== "string") throw new Error("invalid payload.locale");
}
async function consume(raw: string) {
const parsed: unknown = JSON.parse(raw);
assertSendWelcomeEmailMessageV2(parsed);
await sendWelcomeEmailOnce(parsed.idempotencyKey, parsed.payload);
}
async function sendWelcomeEmailOnce(
idempotencyKey: string,
payload: SendWelcomeEmailPayloadV2
) {
// Pseudocode: cek store idempotency sebelum efek samping
// jika key sudah pernah sukses diproses, skip
console.log("send email", idempotencyKey, payload.email);
}Poin penting dari contoh di atas:
- Tipe statis membantu developer saat menulis producer/consumer.
- Validasi runtime tetap diperlukan karena data yang datang dari queue bisa rusak, usang, atau dikirim oleh layanan lain yang belum sinkron.
- Envelope generik membuat metadata operasional konsisten untuk semua job.
Producer juga harus memvalidasi
Kesalahan umum adalah hanya memvalidasi di consumer. Producer sebaiknya membuat fungsi pembangun pesan yang memastikan schema valid sebelum publish.
function buildWelcomeEmailMessage(input: {
userId: string;
email: string;
locale: string;
}): SendWelcomeEmailMessageV2 {
return {
messageId: crypto.randomUUID(),
jobType: "email.send_welcome",
schemaVersion: 2,
occurredAt: new Date().toISOString(),
producer: "user-service",
idempotencyKey: `welcome-email:${input.userId}`,
retry: {
attempt: 0,
maxAttempts: 5,
nextVisibleAt: new Date().toISOString()
},
payload: input
};
}Dengan pola ini, producer tidak menyusun JSON bebas secara manual. Ini mengurangi risiko typo field, nilai kosong yang tidak terduga, atau versi yang salah.
Versioning kontrak tanpa memicu insiden
Kapan perlu menaikkan schemaVersion
Naikkan versi saat perubahan dapat memengaruhi cara consumer memahami payload, misalnya:
- mengubah nama field,
- mengubah tipe field,
- mengubah arti field,
- menghapus field yang sebelumnya dipakai,
- menambahkan field baru yang wajib.
Menambahkan field opsional biasanya kompatibel ke belakang, tetapi tetap perlu dicek: consumer lama mungkin melakukan deserialisasi ketat atau menghitung hash payload yang berubah.
Strategi kompatibilitas maju dan mundur
- Backward compatible: consumer baru masih bisa membaca pesan lama.
- Forward compatible: consumer lama tidak rusak saat menerima field tambahan yang belum dikenal.
Untuk queue, target minimal yang aman biasanya backward compatibility di consumer, karena pesan lama bisa tertahan di broker dan baru diproses setelah deploy baru.
Pola migrasi yang aman
- Deploy consumer yang bisa membaca versi lama dan baru.
- Ubah producer agar mulai mengirim versi baru.
- Pantau error rate, DLQ, dan distribusi versi pesan.
- Setelah antrian versi lama habis dan semua producer diperbarui, hapus dukungan versi lama.
Jangan langsung mengubah producer lebih dulu jika consumer lama belum siap. Di sistem terdistribusi, urutan deploy memengaruhi keselamatan kontrak.
Contoh adapter versi
type V1 = Envelope<"email.send_welcome", { userId: string; email: string }>;
type V2 = Envelope<"email.send_welcome", { userId: string; email: string; locale: string }>;
function normalizeToV2(msg: V1 | V2): V2 {
if (msg.schemaVersion === 2) return msg;
return {
...msg,
schemaVersion: 2,
payload: {
...msg.payload,
locale: "id-ID"
}
};
}Adapter seperti ini berguna untuk menjaga logika bisnis utama tetap fokus pada satu bentuk data internal.
Idempotency, retry metadata, dan dead-letter queue
Idempotency key
Jika worker memanggil API eksternal, menulis ke database, atau mengirim notifikasi, duplikasi eksekusi harus diantisipasi. idempotencyKey sebaiknya stabil terhadap operasi bisnis, bukan terhadap percobaan teknis. Misalnya invoice-paid:inv-123, bukan messageId yang berubah tiap publish ulang.
Retry metadata
Simpan metadata retry di envelope agar consumer dapat:
- menerapkan backoff,
- membedakan error sementara dan permanen,
- mencatat attempt ke log/metric,
- menghentikan retry setelah batas tertentu.
Retry sebaiknya tidak dilakukan membabi buta. Error validasi schema biasanya non-retryable dan lebih tepat diarahkan ke DLQ. Sebaliknya, timeout database atau kegagalan jaringan biasanya retryable.
Dead-letter queue
DLQ bukan tempat membuang pesan gagal tanpa konteks. Simpan informasi minimum berikut:
- payload asli,
- error class atau reason code,
- waktu gagal,
- attempt terakhir,
- service/worker yang memproses,
- traceId dan messageId.
Tanpa metadata itu, proses re-drive dari DLQ akan sulit dan rawan mengulang bug yang sama.
Validasi di producer dan consumer: jangan pilih salah satu
Producer validation
Tujuannya mencegah pesan salah kontrak masuk ke broker. Ini menurunkan kebisingan operasional dan mempercepat feedback ke developer.
Consumer validation
Tujuannya melindungi worker dari data tak terpercaya. Consumer harus menganggap queue sebagai input eksternal, bahkan jika producer berada dalam organisasi yang sama.
Schema registry vs shared library
Dua pendekatan yang umum:
- Shared library schema: sederhana untuk ekosistem yang homogen dan repositori terkoordinasi. Cocok jika producer-consumer menggunakan bahasa yang sama atau tooling kompatibel.
- Schema registry / schema document: lebih baik untuk polyglot system, banyak tim, dan governance kontrak yang lebih formal.
Jika memilih shared library, hati-hati dengan coupling deploy. Library bersama memudahkan konsistensi, tetapi dapat menunda perubahan jika banyak layanan bergantung pada versi yang berbeda.
Locking, cache invalidation, dan consistency
Locking: kapan perlu, kapan tidak
Lock diperlukan jika dua worker dapat memproses resource yang sama secara bersamaan dan menyebabkan race condition. Misalnya sinkronisasi saldo, pembuatan invoice, atau update agregat yang tidak atomik.
Namun, locking bukan pengganti idempotency. Lock membantu serialisasi eksekusi, sedangkan idempotency melindungi dari replay dan duplikasi.
- Gunakan lock per resource, misalnya berdasarkan
userIdatauorderId. - Pastikan ada TTL agar lock tidak menggantung saat worker mati.
- Desain critical section sekecil mungkin.
Cache invalidation
Queue sering memicu update state turunan dan invalidasi cache. Kesalahan umum adalah mengirim payload yang berisi snapshot besar lalu menggunakannya untuk menulis cache lama setelah state sumber sudah berubah.
Praktik yang lebih aman:
- simpan identifier dan versi entity jika memungkinkan,
- ambil ulang data terbaru sebelum menulis cache kritikal,
- gunakan event ordering atau version check untuk mencegah stale write.
Consistency
Queue pada sistem terdistribusi jarang memberi strong consistency end-to-end. Yang realistis adalah menjaga eventual consistency tetap terkendali:
- definisikan operasi yang boleh tertunda,
- jaga idempotency di side effect,
- hindari payload yang mengandung asumsi state lama,
- catat versi state atau timestamp bila urutan penting.
Kesalahan yang sering terjadi
- Menggunakan satu payload generik untuk semua job sehingga bentuk data makin kabur.
- Menyembunyikan breaking change tanpa menaikkan versi schema.
- Menganggap field tambahan selalu aman padahal parser consumer bisa ketat.
- Mengandalkan retry untuk error validasi yang seharusnya langsung masuk DLQ.
- Tidak menyimpan idempotency state hasil sukses, sehingga duplicate processing tetap lolos.
- Mengirim state turunan yang mudah basi alih-alih identifier dan data minimal.
Checklist operasional agar bug kontrak tidak jadi insiden produksi
Checklist desain payload queue
- Setiap
jobTypepunya schema yang tegas dan terdokumentasi. - Envelope dan payload dipisah jelas.
schemaVersioneksplisit dan diuji.- Field wajib, opsional, dan deprecated didefinisikan dengan jelas.
- Payload hanya membawa data yang diperlukan, bukan snapshot berlebihan.
Checklist producer
- Validasi schema sebelum publish.
- Set
messageId,traceId, danidempotencyKey. - Jangan publish job sebelum transaksi sumber aman, atau gunakan pola seperti outbox bila diperlukan.
- Pastikan serialisasi konsisten dan timezone tidak ambigu.
Checklist consumer
- Validasi schema saat menerima pesan.
- Bedakan error retryable dan non-retryable.
- Simpan jejak
messageIddanidempotencyKeydi log/metric. - Normalisasi versi lama ke model internal yang stabil.
- Pastikan side effect aman terhadap duplikasi.
Checklist locking dan consistency
- Tentukan resource key untuk lock bila ada race condition.
- Gunakan TTL lock dan pelepasan yang andal.
- Cegah stale update dengan version check bila urutan penting.
- Evaluasi apakah worker harus membaca ulang state terbaru sebelum menulis.
Checklist DLQ dan observabilitas
- DLQ menyimpan payload asli dan reason code.
- Ada dashboard untuk error per
jobTypedanschemaVersion. - Ada prosedur re-drive yang aman dan terdokumentasi.
- Distribusi versi payload dipantau selama migrasi.
Penutup
Type-safe payload queue yang tahan salah kontrak dibangun dengan disiplin kontrak data, bukan hanya dengan memilih broker yang tepat. Kuncinya adalah schema yang sempit, envelope yang konsisten, versioning eksplisit, validasi di dua sisi, idempotency, dan jalur kegagalan yang bisa dioperasikan.
Jika ingin mulai dari langkah paling berdampak, lakukan tiga hal lebih dulu: definisikan envelope standar, tambahkan schemaVersion dan idempotencyKey ke semua pesan, lalu validasi payload di producer dan consumer. Tiga langkah ini biasanya sudah cukup untuk mencegah banyak bug kontrak berubah menjadi insiden produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!