Dalam integrasi backend, masalah terbesar sering bukan pada format JSON, melainkan pada semantik: apa arti event itu bagi pengirim dan penerima. Dua sistem bisa sama-sama membaca payload yang valid, tetapi menafsirkan maknanya berbeda. Akibatnya, retry menjadi berbahaya, deduplikasi gagal, status bisnis melompat, atau data tampak konsisten padahal maknanya sudah melenceng.

Itulah inti dari kontrak API untuk event semantik: bukan hanya menyepakati nama field, tetapi juga menyepakati arti, versi, perilaku retry, idempotensi, urutan event, dan cara mendeteksi penyimpangan. Analogi singkatnya mirip upaya “mendekode bahasa”: bukan cukup mendengar bunyi, kita harus memahami konteks dan maksud pesannya. Dalam sistem backend, konteks itu harus ditulis jelas dalam kontrak.

Mengapa event semantik sering gagal di integrasi nyata

Failure mode yang paling sering terjadi adalah ketika satu pihak menganggap event sebagai state notification, sedangkan pihak lain menganggapnya sebagai command atau delta perubahan. Misalnya, event order.updated dikirim setiap ada perubahan pada order. Sistem A menganggap payload terakhir adalah representasi penuh order. Sistem B menganggap payload hanya berisi field yang berubah. Hasilnya, field yang tidak ikut terkirim bisa dianggap null, dihapus, atau diabaikan secara tidak konsisten.

Contoh lain: enum status awalnya hanya PENDING, PAID, dan CANCELLED. Beberapa bulan kemudian, pengirim menambah AUTHORIZED. Jika consumer menulis parser dengan asumsi enum tertutup dan tidak menyiapkan fallback, event bisa ditolak, masuk retry loop, lalu menumpuk di antrean. Secara teknis schema tetap “mirip”, tetapi secara semantik integrasi sudah rusak.

Catatan: Valid JSON tidak berarti kontrak aman. Sebuah payload bisa lolos parsing tetapi tetap salah dipahami secara bisnis.

Prinsip desain kontrak API untuk event semantik

1. Bedakan struktur dari makna

Schema menjelaskan bentuk data; kontrak semantik menjelaskan arti data. Dokumentasi event perlu menyatakan secara eksplisit:

  • apa yang dipresentasikan event tersebut,
  • kapan event dipancarkan,
  • apakah payload adalah snapshot penuh atau partial update,
  • apakah event boleh datang lebih dari sekali,
  • apakah event harus diproses berurutan,
  • bagaimana consumer harus menangani nilai yang belum dikenal.

2. Pilih nama event yang menyampaikan niat

Nama event seperti order.updated terlalu umum bila makna bisnisnya penting. Jika yang terjadi sebenarnya adalah transisi tertentu, nama seperti order.payment_authorized atau order.fulfillment_requested lebih aman. Nama yang spesifik mengurangi ruang interpretasi di sisi consumer.

3. Gunakan envelope metadata yang stabil

Jangan campur metadata transport dengan data bisnis. Sebuah event sebaiknya memiliki envelope yang konsisten, misalnya:

{
  "event_id": "evt_01J9Y8...",
  "event_type": "order.payment_authorized",
  "event_version": "2.1",
  "occurred_at": "2026-08-11T10:15:30Z",
  "producer": "billing-service",
  "aggregate_id": "ord_12345",
  "sequence": 18,
  "idempotency_key": "bill-auth-ord_12345-18",
  "data": {
    "order_id": "ord_12345",
    "payment_id": "pay_987",
    "currency": "IDR",
    "amount": 150000
  }
}

Metadata seperti event_id, event_type, event_version, occurred_at, aggregate_id, dan sequence membantu routing, dedup, observability, dan penanganan ordering tanpa mengotori domain payload.

Schema evolution: field opsional, field wajib, dan enum yang berubah

Tambahkan field baru secara kompatibel

Menambah field baru biasanya aman bila consumer diharapkan mengabaikan field yang tidak dikenal. Karena itu, consumer sebaiknya tidak gagal hanya karena ada field tambahan. Sebaliknya, producer tidak boleh berasumsi semua consumer langsung memahami field baru.

Contoh payload awal:

{
  "event_type": "shipment.created",
  "event_version": "1.0",
  "data": {
    "shipment_id": "shp_1001",
    "order_id": "ord_123",
    "carrier": "internal",
    "eta_date": "2026-08-13"
  }
}

Lalu kebutuhan bisnis berkembang: kini ada jenis carrier eksternal dan nomor pelacakan.

{
  "event_type": "shipment.created",
  "event_version": "1.1",
  "data": {
    "shipment_id": "shp_1001",
    "order_id": "ord_123",
    "carrier": "third_party",
    "tracking_number": "TRK-8899123",
    "eta_date": "2026-08-13"
  }
}

Perubahan ini kompatibel jika:

  • tracking_number bersifat opsional untuk versi sebelumnya,
  • consumer tidak mengunci enum carrier hanya ke satu nilai lama,
  • dokumentasi menjelaskan kapan tracking_number ada dan kapan tidak.

Jangan sembrono mengubah field wajib

Perubahan yang paling berbahaya biasanya bukan penambahan field, tetapi perubahan makna field wajib. Misalnya, field amount semula berarti total bruto, lalu diam-diam diubah menjadi neto setelah diskon. Nama field tetap sama, parsing tetap sukses, tetapi seluruh perhitungan consumer menjadi salah.

Bila makna berubah, pilih salah satu:

  • buat field baru dengan nama yang lebih jelas, misalnya gross_amount dan net_amount,
  • naikkan versi event secara eksplisit,
  • pertahankan field lama selama masa transisi.

Opsional vs wajib: aturan praktis

  • Wajib bila field dibutuhkan untuk memahami event secara benar.
  • Opsional bila field hanya memperkaya konteks atau belum tersedia pada semua skenario.
  • Field opsional tetap harus punya semantik jelas: tidak ada, null, dan string kosong bisa berarti hal yang berbeda.

Kesalahan umum adalah mengubah field wajib menjadi opsional tanpa mendefinisikan perilaku fallback. Consumer lalu membuat asumsi sendiri, dan semantik menyimpang antar tim.

Enum harus diperlakukan sebagai himpunan terbuka

Jangan anggap enum akan selamanya tetap. Consumer yang kuat harus memiliki cabang unknown atau fallback aman. Misalnya:

switch (status) {
  case "PENDING":
  case "PAID":
  case "CANCELLED":
    handleKnownStatus(status);
    break;
  default:
    logUnknownEnum(status);
    parkForReview();
}

Fallback ini penting. Lebih baik menaruh event ke jalur peninjauan atau memproses sebagian dengan aman daripada merusak state karena memaksa enum lama pada makna baru.

Versioning yang masuk akal: kapan perlu dan bagaimana memakainya

Gunakan versi untuk perubahan semantik, bukan setiap perubahan kecil

Jika hanya menambah field opsional yang bisa diabaikan, sering kali tidak perlu membuat versi mayor baru. Tetapi bila ada perubahan berikut, versioning hampir selalu dibutuhkan:

  • arti field berubah,
  • field wajib ditambah atau dihapus,
  • payload berubah dari snapshot penuh menjadi partial update,
  • aturan bisnis consumer harus diubah agar tetap benar.

Versi di metadata, bukan hanya di dokumentasi

Simpan versi langsung di payload atau header yang dapat diakses consumer. Jangan bergantung pada “tim sudah tahu” atau tanggal cutover manual. Consumer perlu keputusan deterministik saat memproses event.

Contoh pendekatan yang umum:

  • Versi pada event type: order.created.v2
  • Versi pada field metadata: event_version: "2.0"

Keduanya bisa dipakai. Yang penting konsisten dan mudah dirouting. Banyak tim memilih event_type tetap stabil dan menaruh versi di metadata agar evolusi lebih rapi.

Strategi kompatibilitas mundur

  1. Tambahkan field baru sebagai opsional.
  2. Biarkan producer mengirim field lama dan baru selama masa transisi.
  3. Perbarui consumer agar dapat membaca dua bentuk payload.
  4. Pasang metrik adopsi versi consumer.
  5. Baru hapus field lama setelah semua consumer bermigrasi.

Ini lebih lambat, tetapi jauh lebih aman dibanding perubahan serentak yang bergantung pada koordinasi manual lintas tim.

Idempotensi, dedup webhook, dan retry yang aman

Mengapa retry hampir pasti terjadi

Dalam sistem terdistribusi, timeout, putus koneksi, restart worker, dan error 5xx akan terjadi. Karena itu, producer atau delivery platform hampir pasti mengirim event yang sama lebih dari sekali. Jika consumer menganggap setiap request adalah event baru, Anda akan mendapat duplikasi side effect: invoice ganda, email berulang, stok berkurang dua kali, atau status mundur.

Idempotency key bukan sekadar request ID

Idempotency key adalah identitas operasi logis, bukan hanya identitas paket jaringan. Jika event yang sama dikirim ulang lima kali, semua percobaan harus mengacu pada kunci yang sama agar side effect dieksekusi maksimal satu kali.

Contoh tabel dedup sederhana:

CREATE TABLE processed_events (
  idempotency_key VARCHAR(128) PRIMARY KEY,
  event_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(128) NOT NULL,
  processed_at TIMESTAMP NOT NULL,
  response_code INT NULL
);

Alur consumer yang aman:

  1. Terima webhook atau event dari queue.
  2. Ambil idempotency_key atau turunkan dari kombinasi yang stabil, misalnya event_type + aggregate_id + sequence.
  3. Coba simpan ke tabel dedup dalam transaksi.
  4. Jika insert gagal karena kunci sudah ada, anggap event sudah diproses dan kembalikan sukses idempoten.
  5. Jika berhasil, jalankan side effect bisnis lalu commit.

Poin pentingnya: cek dedup dan perubahan state bisnis idealnya terjadi dalam unit transaksi yang konsisten. Jika dedup disimpan setelah side effect, crash di tengah proses masih bisa menyebabkan duplikasi saat retry.

Webhook dedup: gunakan event ID, tetapi pahami batasnya

Banyak provider mengirim event_id unik. Ini baik untuk dedup, tetapi tidak selalu cukup untuk idempotensi bisnis. Misalnya, provider bisa mengirim dua event berbeda yang mewakili operasi bisnis yang sama setelah failover. Karena itu:

  • gunakan event_id untuk dedup transport,
  • gunakan idempotency_key untuk dedup operasi bisnis,
  • pastikan TTL penyimpanan dedup cukup lama untuk menutup jendela retry realistis.

Retry aman: bedakan error sementara dan permanen

Tidak semua kegagalan layak di-retry.

  • Retry untuk timeout, gangguan jaringan, lock contention sementara, atau dependency yang sementara gagal.
  • Jangan retry tanpa batas untuk payload invalid, schema tidak dikenali, signature webhook salah, atau field wajib hilang.

Gunakan backoff bertahap dan dead-letter queue untuk event yang gagal berulang. Tanpa itu, satu payload buruk bisa menghambat antrean panjang dan menutupi akar masalah.

Ordering event: kapan wajib, kapan cukup eventual consistency

Jangan mengasumsikan urutan global

Di sistem event-driven, urutan global hampir selalu mahal atau tidak realistis. Yang biasanya lebih masuk akal adalah menjaga urutan per entitas, misalnya per order_id atau account_id. Karena itu, tambahkan aggregate_id dan bila perlu sequence atau version pada event.

Contoh failure mode akibat ordering

Misalnya consumer menerima dua event:

  • order.cancelled dengan sequence=11
  • order.paid dengan sequence=10

Jika order.cancelled datang lebih dulu lalu consumer tidak mengecek sequence, status akhir bisa menjadi PAID saat event kedua tiba belakangan. Padahal secara bisnis order sudah dibatalkan.

Pendekatan yang lebih aman:

  • simpan sequence terakhir per aggregate,
  • tolak atau parkir event yang lebih lama dari state terkini,
  • jika gap sequence terdeteksi, putuskan apakah perlu menunggu, melakukan re-fetch snapshot, atau menerima eventual consistency.

Snapshot vs delta event

Jika ordering sulit dijamin, event snapshot sering lebih mudah dikelola dibanding delta event. Snapshot membawa state terbaru yang cukup lengkap untuk sinkronisasi. Delta lebih hemat payload, tetapi lebih rapuh terhadap event hilang atau datang terlambat.

Pilih snapshot bila:

  • consumer banyak dan heterogen,
  • sinkronisasi lebih penting daripada efisiensi payload,
  • ordering sulit dijaga.

Pilih delta bila:

  • throughput tinggi dan payload besar,
  • consumer memahami urutan dengan baik,
  • ada mekanisme replay atau rekonsiliasi yang matang.

Observability: cara melihat kontrak rusak sebelum jadi insiden besar

Metrik yang sebaiknya ada

  • jumlah event per event_type dan versi,
  • rate retry dan dead-letter,
  • jumlah event duplikat yang tertahan oleh dedup,
  • jumlah enum/field tidak dikenal,
  • selisih sequence atau out-of-order rate,
  • latensi dari occurred_at ke waktu proses consumer.

Logging yang berguna untuk debugging

Minimal log berikut perlu tersedia:

  • event_id, event_type, event_version,
  • aggregate_id dan sequence,
  • hasil validasi schema,
  • keputusan idempotensi: baru, duplikat, atau replay,
  • error class: transient atau permanent.

Tanpa metadata ini, tim biasanya hanya melihat “consumer gagal”, tetapi tidak tahu apakah masalahnya versi payload, urutan event, atau duplikasi retry.

Tracing lintas layanan

Jika memungkinkan, teruskan correlation ID atau trace ID dari producer ke consumer. Ini membantu saat satu event memicu beberapa side effect ke sistem lain. Debugging jadi jauh lebih cepat karena satu alur bisnis dapat ditelusuri melintasi service, queue, dan webhook.

Webhook vs polling: kapan memakai yang mana

Pilih webhook bila Anda butuh reaksi cepat

Webhook cocok bila perubahan harus diketahui segera, misalnya status pembayaran, pengiriman, atau provisioning. Kelebihannya adalah latensi rendah dan tidak perlu terus-menerus menanyakan status. Kekurangannya: Anda harus siap menerima retry, duplikasi, urutan yang tidak terjamin, dan kebutuhan verifikasi keamanan seperti signature.

Pilih polling bila sumber event tidak stabil atau kontraknya lemah

Polling cocok bila:

  • provider tidak menyediakan webhook yang andal,
  • Anda lebih butuh snapshot state terbaru daripada tiap perubahan kecil,
  • volume perubahan rendah,
  • keterlambatan beberapa detik atau menit masih dapat diterima.

Polling lebih sederhana secara operasional, tetapi lebih boros request dan memiliki latensi lebih tinggi. Dalam beberapa integrasi, pendekatan hibrida justru paling aman: webhook untuk sinyal cepat, polling atau rekonsiliasi periodik untuk memastikan tidak ada event yang terlewat.

Contoh perubahan kontrak: sebelum dan sesudah

Versi awal yang ambigu

{
  "event_type": "order.updated",
  "data": {
    "order_id": "ord_5001",
    "status": "PAID",
    "amount": 250000
  }
}

Masalah pada kontrak ini:

  • order.updated terlalu umum,
  • tidak jelas apakah payload snapshot atau delta,
  • amount tidak jelas bruto atau neto,
  • tidak ada versi, event ID, sequence, atau idempotency key.

Versi yang lebih eksplisit

{
  "event_id": "evt_7fa2c",
  "event_type": "order.payment_captured",
  "event_version": "2.0",
  "occurred_at": "2026-08-11T10:15:30Z",
  "producer": "payment-service",
  "aggregate_id": "ord_5001",
  "sequence": 12,
  "idempotency_key": "order-payment-captured-ord_5001-12",
  "data": {
    "order_id": "ord_5001",
    "payment_id": "pay_7009",
    "currency": "IDR",
    "gross_amount": 250000,
    "net_amount": 245000,
    "fee_amount": 5000,
    "captured_at": "2026-08-11T10:15:28Z"
  }
}

Versi kedua lebih aman karena:

  • nama event menyampaikan transisi bisnis yang jelas,
  • field uang punya makna eksplisit,
  • ada metadata untuk dedup, tracing, dan ordering,
  • consumer dapat memutuskan logika berdasarkan event_version.

Checklist review kontrak API dan event

  1. Apakah nama event cukup spesifik untuk menyampaikan niat bisnis?
  2. Apakah jelas ini snapshot penuh atau delta perubahan?
  3. Apakah setiap field wajib benar-benar dibutuhkan untuk interpretasi yang benar?
  4. Apakah makna null, field hilang, dan string kosong dibedakan?
  5. Apakah enum diperlakukan sebagai himpunan terbuka dengan fallback aman?
  6. Apakah versi tersedia di metadata dan didokumentasikan?
  7. Apakah ada event_id dan idempotency_key yang stabil?
  8. Apakah duplicate delivery sudah diasumsikan sebagai hal normal?
  9. Apakah strategi retry membedakan error sementara dan permanen?
  10. Apakah ada aggregate_id dan mekanisme ordering bila dibutuhkan?
  11. Apakah consumer tahu apa yang harus dilakukan saat menerima event lama atau out-of-order?
  12. Apakah metrik, log, dan trace cukup untuk mendeteksi drift semantik?
  13. Apakah ada masa transisi untuk kompatibilitas mundur saat kontrak berubah?
  14. Apakah tersedia jalur replay atau rekonsiliasi bila event hilang?

Kesalahan umum yang sering terjadi

  • Menganggap validasi schema saja sudah cukup.
  • Menggunakan event generik seperti updated untuk banyak makna berbeda.
  • Menambah enum baru tanpa fallback di consumer.
  • Mengandalkan urutan kirim sebagai urutan terima.
  • Menyimpan dedup setelah side effect dijalankan.
  • Me-retry semua error tanpa klasifikasi.
  • Menghapus field lama terlalu cepat sebelum semua consumer migrasi.

Penutup

Kontrak API untuk event semantik yang baik harus dirancang untuk dunia nyata: payload akan berevolusi, event akan dikirim ulang, sebagian akan datang terlambat, dan beberapa consumer akan tertinggal versinya. Karena itu, desain yang aman biasanya mencakup envelope metadata yang stabil, versioning yang eksplisit, field dengan makna jelas, enum yang toleran terhadap nilai baru, idempotensi yang benar-benar diterapkan di storage, retry yang terkontrol, serta observability yang cukup untuk mendeteksi drift semantik.

Jika dua sistem bisa membaca pesan yang sama tetapi menarik kesimpulan berbeda, masalahnya bukan lagi serialisasi, melainkan kontrak. Dan kontrak yang baik selalu menulis makna, bukan hanya bentuk.