Benchmark Payment Gateway Bagian 1 : Kenapa Dibandingkan Dulu Sebelum Dibangun
Setiap kali kami di ArtiVisi membangun sistem yang berurusan dengan uang, pertanyaan ini hampir selalu datang dari internal tim engineering: “Kenapa tidak pakai event sourcing saja?” Pertimbangan teknis seperti skalabilitas atau audit trail otomatis dari event log sering mengemuka, di samping daya tarik arsitekturnya yang modern untuk portofolio.
Kali ini kami memilih membuktikannya secara langsung: membangun kedua arsitektur tersebut, mengujinya secara head-to-head, dan membiarkan data yang berbicara. Proses pembuktiannya ternyata jauh lebih menarik dibanding hasil akhirnya — termasuk momen ketika hasil benchmark beserta AI yang mengerjakannya sempat terkecoh oleh hasil rakitannya sendiri. Di bagian pertama ini, mari kita mulai dari dasar: mengapa perbandingan ini perlu dirumuskan bahkan sebelum satu baris kode pun ditulis.
Konteks: Payment Gateway Virtual Account Multi-Bank
Sistem yang kami bangun adalah gateway penagihan Virtual Account (VA) untuk institusi yang terhubung langsung ke beberapa bank sekaligus — mirip sistem pembayaran SPP universitas atau tagihan rumah sakit. Operator sistem memegang langsung rekening settlement dan integrasi ke bank; gateway ini sama sekali tidak mengendapkan dana.
Domain bisnisnya memiliki sejumlah aturan main yang membuat arsitekturnya tidak sesederhana “terima uang, lalu update saldo”:
- Satu tagihan (
Charge) bisa dibayar melalui beberapa bank sekaligus (pay-via-any-bank). Pelanggan bebas membuat nomor VA di Bank A maupun Bank B untuk tagihan yang sama, dan transaksi yang pertama kali lunas adalah yang menang. - Ada tiga tipe tagihan dengan karakter berbeda:
CLOSED(sekali bayar lunas, langsung ditutup),INSTALLMENT(bisa dicicil hingga batas maksimal), danOPEN(rekening selalu aktif seperti rekening donasi, tidak pernah ditutup meski dibayar berulang kali). - Nomor VA bersifat terbatas dan didaur ulang. Satu nomor VA bisa digunakan untuk tagihan berbeda secara bergantian, asalkan tidak ada dua tagihan yang memakai nomor tersebut secara bersamaan.
- Rekonsiliasi harian wajib mendeteksi pembayaran ganda (double settlement). Jika dua bank melaporkan pelunasan untuk tagihan yang sama di saat bersamaan, sistem harus mencatatnya sebagai anomali yang perlu ditangani.
Poin terakhir inilah yang nantinya menjadi inti drama di bagian 3 dan 4. Simpan dulu fakta ini.
Dua Kandidat Arsitektur
Untuk kebutuhan seperti ini, terdapat dua pendekatan arsitektur yang masuk akal:
Opsi A — Modular Monolith Relasional. Menggunakan aplikasi Spring Boot dan database PostgreSQL dengan transaksi ACID standar. Validasi pembayaran serta penguncian baris data (SELECT FOR UPDATE) dieksekusi dalam satu transaksi database yang utuh.
flowchart LR
App1["Spring Boot App"] --> DB1[("PostgreSQL
ACID Transaction")]
Opsi B — Event-Sourced / CQRS. Setiap perubahan state dicatat sebagai event ke Apache Kafka. Kafka Streams yang dipadukan dengan RocksDB sebagai state store lokal bertindak sebagai single writer per partition key. Sementara itu, PostgreSQL hanya berfungsi sebagai read model (proyeksi) yang diperbarui secara asinkron dari event stream.
flowchart LR
App2["Spring Boot App"] --> Kafka["Kafka
Event Log"]
Kafka --> Streams["Kafka Streams
+ RocksDB"]
Kafka --> Sink["Projection Sink"]
Sink --> DB2[("PostgreSQL
Read Model")]
Kedua opsi arsitektur ini memiliki landasan teknis yang sama-sama solid, dengan trade-off masing-masing yang perlu ditimbang matang-matang sebelum mulai menyentuh keyboard.
Analisis Trade-off Sebelum Ada Kode Sama Sekali
Sebelum menulis satu baris kode pun, kami merumuskan argumen untuk masing-masing opsi ke dalam dokumen architecture-comparison.md yang menjadi rujukan sepanjang proyek. Berikut ringkasan poin utamanya:
| Dimensi | Opsi A (Monolith Relasional) | Opsi B (Event-Sourced/CQRS) |
|---|---|---|
| Ingress protokol | Synchronous request-response langsung | Synchronous, tapi validasi lokal dulu baru append async ke Kafka |
| Source of truth | Relational database | Event log + RocksDB state store lokal |
| Concurrency control | Row lock (SELECT FOR UPDATE) per transaksi | Single writer per Kafka partition key, tanpa lock |
| Invariant lintas entitas | Atomic multi-row transaction, bawaan default | Bisa atomic juga, tapi butuh desain yang tepat untuk menghindari pola detect-and-flag |
| Overhead operasional | Minimal — 1 app + 1 database | Sedang — tambah 1 Kafka broker + komponen streaming |
Baris “Invariant lintas entitas” paling menarik jika ditinjau dari sisi teori — dan ini salah satu poin yang kami revisi setelah seri ini terbit, jadi baiknya diluruskan dari awal. Pada Opsi A, jika dua bank melaporkan pelunasan tagihan yang sama di waktu yang hampir bersamaan, SELECT FOR UPDATE memobilisasi mekanisme antrean lock untuk mencegah race condition di tingkat database — dan ini didapat gratis, tanpa desain ekstra apa pun.
Pada Opsi B, pola yang paling umum dipakai (dan yang dipakai implementasi pembanding kami di awal) adalah: request thread memvalidasi state lokal di RocksDB lebih dulu, baru append event ke Kafka untuk diproses belakangan oleh thread lain. Alhasil, ada jeda antara “sudah divalidasi” dan “benar-benar dieksekusi oleh single writer” — dan di celah itulah dua request bisa lolos validasi awal sebelum salah satunya terdeteksi terlambat (persis yang terjadi di Bagian 4). Tapi ini bukan keterbatasan RocksDB atau Kafka Streams itu sendiri — RocksDB punya dukungan transaksi (optimistic maupun pessimistic) yang bisa dipakai langsung di request thread untuk melakukan pencarian, validasi, dan penulisan sebagai satu operasi atomic, tanpa celah sama sekali. Persoalannya murni soal pola implementasi mana yang dipilih.
Kesimpulan sementara di atas kertas: Opsi A dipilih sebagai arsitektur utama. Untuk skala beban kerja institusi (puluhan hingga ratusan TPS pada puncak jam sibuk), kompleksitas ekstra Opsi B terasa berlebihan dibanding manfaatnya — termasuk beban ekstra untuk memastikan pola atomic yang benar dipakai sejak awal, sesuatu yang di Opsi A datang gratis tanpa perlu dipikirkan.
Mengapa Tidak Berhenti di Sini?
Dokumen trade-off sudah memberikan dasar keputusan yang kokoh. Kendati demikian, kami melanjutkan ke tahap pembangunan dan pengujian karena dua alasan:
- Klaim teoritis perlu dibuktikan. Pernyataan seperti “PostgreSQL sanggup menangani 5.000–10.000 TPS” memerlukan verifikasi melalui data empiris.
- Opsi B butuh wujud nyata. Menjawab pertanyaan teknis secara kredibel memerlukan sistem pembanding yang benar-benar berjalan dan diuji.
Catatan penting lainnya: kedua proyek ini dibangun memanfaatkan AI coding agent. Kami melibatkan dua agent berbeda: Opsi A (relasional) dikerjakan penuh oleh Claude Code, sedangkan versi awal Opsi B (event-sourced) digarap oleh Gemini Flash melalui Antigravity CLI. Audit, pembenahan bug, hingga eksekusi benchmark kemudian ditangani oleh Claude Code. Seri tulisan ini turut merekam dinamika saat satu AI agent menguji dan membenahi hasil kerja agent lain.
Konteks ini krusial, sebab di Bagian 2 nanti terkuak bahwa implementasi awal Opsi B beserta angka benchmark bawaannya menyimpan celah mendasar.
Pada bagian berikutnya, kita akan mengulas bagaimana sistem pembanding (event-sourced berbekal Kafka Streams dan RocksDB) dirancang dan diuji secara objektif.
Stay tuned …