RUMUS APLIKASI
Pengantar Penulis
Saya menulis buku ini karena terlalu sering melihat ide aplikasi yang bagus mati di tengah jalan — bukan karena idenya jelek, melainkan karena pembuatnya tidak punya peta. Mereka tahu ingin membangun apa, tetapi tidak tahu urutan langkahnya: dari mana mulai, lapisan mana yang dikerjakan dulu, keputusan teknis mana yang akan menghantui mereka enam bulan kemudian. Buku ini adalah peta itu.
Judulnya “Rumus Aplikasi” bukan tanpa alasan. Membangun produk digital dari nol bukanlah seni gaib yang hanya dikuasai segelintir orang. Ada pola. Ada urutan. Ada keputusan yang hampir selalu benar untuk MVP, dan keputusan yang hampir selalu menjebak. Saya merangkum pola-pola itu menjadi rumus yang bisa Anda ikuti — bukan untuk membatasi kreativitas, melainkan untuk membebaskan Anda dari kesalahan yang sudah ribuan orang lakukan sebelumnya.
Inti. Aplikasi yang jadi bukan aplikasi yang paling canggih, melainkan aplikasi yang selesai, dipakai, dan bisa dirawat. Buku ini fokus pada tiga hal itu, dari ide sampai deploy.
Buku ini menyeberangi seluruh lapisan: dari brainstorming ide bersama AI, menulis dokumen Market Discovery, merancang arsitektur Modular Monolith, mendesain database tanpa foreign key dengan soft delete wajib, membangun UI dengan sidebar kiri dan CRUD modal kanan, menulis backend dalam belasan bahasa, hingga deployment, skalabilitas, dan monetisasi. Setiap konsep saya sertai contoh kode nyata dalam bahasa yang relevan — JavaScript, TypeScript, PHP, Python, Java, Go, dan SQL.
Saya tidak meringkas. Bila sebuah topik butuh sepuluh halaman, ia mendapat sepuluh halaman. Anda boleh membacanya berurutan seperti kursus, atau melompat ke bab yang Anda butuhkan seperti ensiklopedia. Selamat membangun.
Cara Membaca Buku Ini
Buku ini terdiri dari sepuluh bagian plus lampiran, mengikuti urutan alami pembuatan aplikasi: ide → arsitektur → desain → backend → frontend → database → deployment → jenis aplikasi → skala → bisnis. Anda bisa mengikutinya sebagai alur kerja, atau membuka satu bab sebagai rujukan.
Peta besar buku
Penanda di dalam teks
| Penanda | Arti |
|---|---|
| Inti | Satu kalimat pokok yang wajib diingat |
| Rumus | Pola/keputusan yang direkomendasikan hampir selalu |
| Tips | Cara praktis mempercepat hasil |
| Awas | Risiko, jebakan, atau batas |
| Contoh | Penerapan konkret dengan kode atau skenario |
Awas. Nama produk, versi framework, dan harga layanan berubah cepat. Buku ini menekankan prinsip dan pola lebih dulu, baru alat. Bila sebuah alat berganti nama saat Anda membaca, rumusnya tetap berlaku.
Fondasi Ide & Brainstorming
Brainstorming Ide dengan AI
Dua puluh tahun lalu, seorang developer yang ingin membangun produk baru harus duduk berjam-jam di depan papan tulis, mengumpulkan kolega, atau membeli buku tebal tentang analisis bisnis. Kini, dalam hitungan menit, Anda bisa mengobrol dengan model bahasa besar seperti ChatGPT, Claude, atau Gemini, dan mendapatkan puluhan ide terstruktur, sanggahan, perbandingan pasar, hingga kerangka monetisasi sekaligus. Namun, kekuatan itu hanya berguna jika Anda tahu cara memakainya. Prompt yang buruk menghasilkan jawaban klise; prompt yang tepat menghasilkan terobosan nyata.
Mengapa AI Sangat Berguna di Fase Brainstorming
Brainstorming konvensional punya dua musuh terbesar: bias konfirmasi (kita cenderung mengiyakan ide sendiri) dan keterbatasan referensi (kita hanya tahu apa yang pernah kita baca). AI mengatasi keduanya. Model bahasa dilatih pada miliaran dokumen, mulai dari paper akademis, laporan industri, thread forum startup, hingga kode sumber publik. Ketika Anda memintanya mengkritik ide Anda, ia tidak punya kepentingan emosional untuk melindungi perasaan Anda.
Lebih dari itu, AI bekerja sebagai rubber duck yang bisa balik bertanya. Bedanya dengan rubber duck biasa: rubber duck ini membaca dokumen SaaS terbaru dan bisa menyebut nama kompetitor yang belum Anda dengar.
Inti. AI bukan mesin jawab; ia adalah mitra dialog. Gunakan ia untuk mempertanyakan asumsi, memperluas referensi, dan mempercepat iterasi ide, bukan sebagai oracle yang selalu benar.
Memilih Platform: ChatGPT, Claude, atau Gemini
Ketiganya unggul di area berbeda. ChatGPT (GPT-4o) sangat baik untuk eksplorasi terbuka dan koneksi ide yang tidak terduga. Claude (Sonnet/Opus) dikenal menghasilkan teks yang lebih hati-hati, kurang halusinasi, dan lebih baik untuk analisis dokumen panjang seperti laporan keuangan atau spesifikasi teknis. Gemini Pro unggul saat Anda perlu menghubungkan informasi real-time (dengan grounding dari Google Search) atau ketika Anda bekerja di dalam ekosistem Google Workspace.
Untuk sesi brainstorming awal, gunakan ChatGPT atau Claude secara bergantian. Dapatkan ide dari ChatGPT, lalu minta Claude mengevaluasinya secara kritis. Perbandingan dua sudut pandang berbeda jauh lebih kaya daripada satu sumber saja.
Prompt Template untuk Ide MVP
Prompt yang efektif memiliki empat komponen: konteks (siapa Anda, apa latar belakangnya), tugas (apa yang ingin Anda hasilkan), batasan (kendala nyata seperti anggaran atau waktu), dan format output (bagaimana Anda ingin jawaban disusun). Berikut adalah template dasar yang bisa langsung Anda gunakan:
# PROMPT TEMPLATE — IDE EKSPLORASI MVP
Kamu adalah konsultan produk berpengalaman 10 tahun di startup Asia Tenggara.
Saya adalah [developer solo / tim 2 orang / startup tahap awal] di Indonesia.
Konteks saya:
- Keahlian teknis: [mis. full-stack web, mobile, data engineering]
- Anggaran pengembangan: [mis. < Rp 50 juta selama 3 bulan]
- Domain yang saya pahami: [mis. logistik, pendidikan, keuangan UMKM]
- Target pengguna awal: [mis. pemilik warung makan, guru SD, karyawan HRD]
Tugas:
1. Berikan 10 ide produk digital (web/mobile/SaaS) yang:
- Menyelesaikan masalah nyata yang sudah ada (bukan menciptakan kebutuhan baru)
- Bisa dibangun oleh tim kecil dalam 3 bulan
- Punya jalur monetisasi yang jelas (subscription, marketplace fee, atau B2B license)
2. Untuk setiap ide, berikan: nama produk, masalah yang diselesaikan, pengguna utama,
ukuran pasar perkiraan (kecil/sedang/besar), dan kendala terbesar.
3. Tandai 3 ide terbaik dengan bintang (*) dan jelaskan mengapa.
Format output: tabel markdown dengan kolom Nama | Masalah | Pengguna | Pasar | Kendala | Bintang
Template di atas menghasilkan output terstruktur yang mudah Anda evaluasi. Setelah mendapat hasilnya, gunakan prompt lanjutan untuk menggali lebih dalam:
# PROMPT LANJUTAN — MENGGALI IDE TERPILIH
Fokuskan pada ide nomor [X]: [nama ide].
Lakukan analisis mendalam dengan kerangka berikut:
1. MASALAH (Problem Framing)
- Nyatakan masalah dalam satu kalimat yang spesifik (bukan generik)
- Siapa yang paling merasakan masalah ini? (persona konkret)
- Seberapa sering masalah ini terjadi? (daily / weekly / monthly)
- Berapa kerugian nyata yang ditimbulkan? (waktu, uang, reputasi)
2. SOLUSI
- Apa solusi minimal yang sudah dipakai pengguna sekarang? (workaround)
- Apa kekurangan solusi tersebut?
- Bagaimana produk kami berbeda dalam 1 kalimat?
3. PASAR
- Sebutkan 3 kompetitor langsung (ada atau tidak, lokal atau global)
- Berikan estimasi TAM / SAM / SOM untuk Indonesia
4. RISIKO TERBESAR
- Risiko teknis (apa yang paling sulit dibangun)
- Risiko bisnis (kenapa pengguna tidak mau bayar)
- Risiko regulasi (apakah ada aturan yang membatasi)
Output sebagai laporan naratif 300-500 kata.
Tips. Simpan semua output AI dalam satu dokumen Notion atau Obsidian. Beri tag setiap ide:
#dikaji,#dibuang,#diperdalam. Setelah satu minggu brainstorming, Anda akan punya basis pengetahuan yang bisa dirujuk kembali sebelum coding dimulai.
Kerangka Analisis: Masalah → Solusi → Pasar
Banyak developer langsung loncat ke solusi (teknologi apa yang akan dipakai) tanpa memastikan masalahnya nyata dan pasarnya cukup besar. Kerangka tiga langkah berikut memaksa Anda berurutan:
Langkah 1 — Validasi Masalah. Tuliskan masalah dalam format: “[Persona] kesulitan [melakukan X] ketika [situasi Y] sehingga [konsekuensi Z].” Contoh: “Pemilik warung makan di pinggiran kota kesulitan mencatat stok bahan baku secara real-time ketika sedang melayani pembeli, sehingga sering kekurangan stok di jam ramai dan kehilangan potensi penjualan hingga Rp 2 juta per minggu.” Jika Anda tidak bisa mengisi konsekuensi Z dengan angka konkret, masalahnya belum cukup dalam.
Langkah 2 — Formulasi Solusi. Solusi MVP adalah solusi paling sederhana yang menghilangkan konsekuensi Z. Bukan yang paling canggih, bukan yang paling lengkap. Tanyakan kepada AI: “Apa solusi paling minimal yang bisa menyelesaikan masalah ini tanpa membangun fitur baru sama sekali?” Jawabannya sering mengejutkan: kadang solusinya adalah spreadsheet Google Form yang otomatis, bukan aplikasi native.
Langkah 3 — Estimasi Pasar. Gunakan pendekatan bottom-up: hitung jumlah persona di Indonesia (data BPS, Asosiasi UMKM, atau Statista), estimasikan berapa persen yang mau membayar, dan berapa harga yang wajar. Jangan pakai angka besar tanpa dasar. Pasar “semua UMKM Indonesia” (67 juta) bukan target pasar Anda; target awal Anda mungkin “warung makan di kota menengah Jawa dengan omzet Rp 5–20 juta/bulan” yang jumlahnya ratusan ribu saja.
5 Metode Validasi Ide Sebelum Coding
Validasi bukan tentang membuktikan bahwa ide Anda bagus. Validasi adalah proses aktif untuk mencari alasan mengapa ide Anda bisa gagal, dan melakukannya secepat mungkin sebelum Anda menulis satu baris kode pun.
Metode 1 — Wawancara Pengguna (Customer Discovery Interview). Lakukan minimal 10 wawancara dengan orang yang cocok dengan persona target Anda. Aturan emas: jangan pitching ide Anda. Tanyakan tentang kehidupan mereka, kebiasaan kerja, dan frustrasi mereka. Pertanyaan terbaik: “Ceritakan pengalaman terakhir kali Anda mencoba [X].” Dengarkan kata-kata yang mereka pakai; kata-kata itu akan menjadi copy terbaik untuk landing page Anda. Analisis pola dari 10 wawancara: jika 7 dari 10 menyebut masalah yang sama, masalah itu nyata.
Metode 2 — Landing Page Test. Buat landing page sederhana (pakai Carrd, Framer, atau bahkan Notion publik) yang mendeskripsikan produk seolah sudah ada. Pasang tombol “Daftar Waiting List” yang meminta email. Jalankan iklan Facebook atau Instagram dengan anggaran Rp 500.000 selama 5 hari. Target spesifik: jika conversion rate email >5% dari yang klik halaman, ada minat nyata. Jika di bawah 1%, pesan atau segmen target Anda meleset.
Metode 3 — Fake Door Test. Mirip landing page test, tetapi lebih agresif. Tambahkan tombol “Beli Sekarang” atau “Coba Gratis” ke halaman yang sudah ada (bisa halaman profil media sosial atau grup komunitas WhatsApp). Ketika pengguna mengklik, mereka disambut pesan: “Produk sedang dalam pengembangan. Masukkan email Anda untuk notifikasi pertama.” Fake door test mengukur intensi pembelian, bukan sekadar rasa penasaran.
Metode 4 — Pre-order / Deposit. Minta komitmen finansial, sekecil apapun. Tawaran: “Bayar Rp 99.000 sekarang, dapatkan akses 6 bulan gratis saat produk launch.” Jika seseorang bersedia membayar uang sungguhan untuk produk yang belum ada, itu sinyal validasi terkuat. Uang bisa dikembalikan jika Anda tidak jadi membangun; kebanyakan orang mengerti. Target minimal: 20 pre-order sebelum mulai coding serius.
Metode 5 — Concierge Prototype / Prototype Test. Lakukan layanan secara manual terlebih dahulu, tanpa teknologi. Jika Anda ingin membangun aplikasi matching antara freelancer desain dan klien UMKM, jalankan prosesnya secara manual dulu: terima brief lewat WhatsApp, cari freelancer dari jaringan pribadi, fasilitasi revisi via email. Lakukan untuk 5 klien. Catat di mana proses paling lambat, paling banyak kesalahan, dan paling sering ditanyakan klien. Itulah bagian yang harus diotomasi oleh MVP Anda.
Awas. Mengandalkan satu metode validasi saja sangat berisiko. Survei Google Form dengan 200 responden yang mengatakan “ya, saya butuh itu” hampir tidak punya nilai validasi karena tidak ada konsekuensi bagi responden untuk berbohong. Gabungkan minimal dua metode: wawancara (untuk insight kualitatif) plus pre-order atau landing page test (untuk sinyal kuantitatif).
Merangkum Hasil Brainstorming ke dalam Satu Dokumen
Setelah sesi brainstorming selesai, Anda harus memiliki satu dokumen ringkas (maksimal 2 halaman A4) yang berisi: nama ide yang dipilih, masalah yang diselesaikan dalam satu kalimat, persona pengguna utama, hasil validasi awal (berapa wawancara, berapa pre-order, berapa klik landing page), dan keputusan: lanjut ke Market Discovery Document atau buang. Dokumen ini bukan formalitas; ini adalah kontrak Anda dengan diri sendiri agar tidak berubah haluan di tengah jalan karena godaan ide baru.
Contoh. Tim dua orang membangun “KasirKu” — aplikasi kasir sederhana untuk warung makan. Setelah 12 wawancara, mereka menemukan bahwa masalah terbesar bukan pencatatan penjualan (sudah pakai buku tulis), melainkan laporan ke pemilik yang tidak ada di lokasi. Pivot kecil: fokus pada fitur laporan real-time WhatsApp otomatis, bukan fitur kasir lengkap. Landing page test: 340 klik iklan, 28 email terdaftar (8,2% conversion). Pre-order: 5 dari 28 yang mendaftar bersedia bayar Rp 150.000/bulan. Keputusan: lanjut ke MD Document.
Menulis Market Discovery (MD) Document
Market Discovery Document (MD Document) adalah jembatan antara ide mentah dan rencana teknis. Ia bukan business plan setebal 40 halaman yang membutuhkan konsultan untuk menulisnya, dan ia bukan sekadar brainstorming liar yang tidak punya struktur. MD Document adalah satu-satunya dokumen yang harus semua anggota tim baca dan setujui sebelum satu baris kode ditulis. Ia menjawab tiga pertanyaan utama: masalah apa yang kita selesaikan, untuk siapa, dan bagaimana kita menghasilkan uang dari itu.
Struktur MD Document yang Wajib Ada
MD Document yang baik terdiri dari lima bagian utama. Urutan ini bukan acak; setiap bagian membangun fondasi untuk bagian berikutnya.
1. Problem Statement. Satu paragraf yang mendeskripsikan masalah dengan spesifik: siapa yang mengalami, kapan terjadi, apa konsekuensinya. Hindari pernyataan generik seperti “banyak UMKM belum terdigitalisasi.” Ganti dengan: “Pemilik warung makan di kota menengah yang mengelola 2–3 karyawan kehilangan rata-rata Rp 1,5 juta/bulan akibat stok yang tidak terpantau real-time, sehingga sering kehabisan bahan baku di jam puncak.”
2. Proposed Solution. Deskripsikan produk dalam satu kalimat nilai (value proposition), lalu 3–5 fitur utama saja. Bukan wishlist fitur; bukan roadmap 2 tahun. Hanya yang diperlukan untuk menyelesaikan masalah di bagian 1. Contoh: “KasirKu adalah aplikasi pencatatan stok dan laporan harian berbasis WhatsApp untuk warung makan yang memiliki 1–3 titik penjualan.”
3. Target Market. Tiga lapis: TAM (Total Addressable Market), SAM (Serviceable Addressable Market), SOM (Serviceable Obtainable Market). Gunakan angka nyata dari sumber kredibel (BPS, Asosiasi, laporan industri). Tambahkan persona primer: nama fiktif, usia, profesi, perangkat yang dipakai, dan kutipan langsung dari wawancara validasi.
4. Competitors Analysis. Minimal 3 kompetitor langsung dan 2 tidak langsung. Buat tabel perbandingan fitur. Jangan hanya sebutkan nama; jelaskan kelemahan utama masing-masing dari sudut pandang target pengguna Anda.
5. Revenue Model. Bagaimana Anda menghasilkan uang? Pilih satu model utama untuk MVP. Subscription bulanan, freemium dengan upgrade, marketplace fee, atau B2B license. Sertakan proyeksi sederhana: jika 100 pengguna aktif membayar Rp 99.000/bulan, pendapatan bulanan adalah Rp 9,9 juta. Berapa biaya operasional (server, tim)? Kapan break even?
Studi Kasus 1 — KasirKu: Aplikasi Kasir UMKM
KasirKu dirancang untuk warung makan di kota tier-2 dan tier-3 Indonesia (Malang, Purwokerto, Pekalongan, Palembang, dsb.) dengan 1–3 karyawan. Berikut MD Document ringkasnya:
# MD Document — KasirKu
**Versi:** 1.0 | **Tanggal:** 2024-11-01 | **Tim:** Budi, Rina
## Problem Statement
Pemilik warung makan skala kecil (omzet Rp 3–20 juta/bulan) tidak punya
visibilitas real-time atas stok dan penjualan harian ketika mereka tidak
berada di lokasi. Catatan buku tulis tidak bisa diakses dari jarak jauh,
rekap manual rentan kesalahan, dan laporan ke pemilik melalui WhatsApp
teks tidak terstruktur dan mudah tertunda.
## Proposed Solution
Aplikasi web mobile-first untuk pencatatan kasir + laporan harian otomatis
via WhatsApp Business API.
Fitur MVP:
1. Input transaksi sederhana (nama menu + harga, tanpa barcode)
2. Stok bahan baku: input pagi, alert ketika stok < threshold
3. Laporan harian: otomatis dikirim ke nomor pemilik pukul 21:00 WIB
4. Dashboard pemilik: total penjualan + stok kritis
## Target Market
- TAM: 7,2 juta warung makan di Indonesia (BPS 2023)
- SAM: 800.000 warung makan di kota menengah dengan smartphone Android
- SOM (tahun 1): 2.000 pengguna aktif berbayar
Persona: Pak Hendra, 42 tahun, pemilik warung nasi padang 3 meja di
Purwokerto. Omzet Rp 8 juta/bulan. Pakai Android murah. Sering tidak di
lokasi karena merangkap kerja sampingan.
## Competitors
| Nama | Kelemahan Utama untuk Segmen Kami |
|------------|---------------------------------------------|
| Moka POS | Terlalu kompleks, harga > Rp 300.000/bulan |
| iReap POS | UI tidak ramah untuk non-tech |
| Buku Warung| Tidak ada fitur stok bahan baku |
| Excel/Buku | Tidak real-time, tidak bisa diakses remote |
## Revenue Model
Freemium: 30 hari gratis → Rp 79.000/bulan (unlimited transaksi)
Proyeksi M-6: 500 pengguna berbayar = Rp 39,5 juta/bulan
Biaya operasional M-6: server Rp 2 juta + tim 2 orang Rp 20 juta = Rp 22 juta
Gross profit M-6: Rp 17,5 juta
Studi Kasus 2 — AbsensiPro: SaaS Absensi untuk Perusahaan Menengah
AbsensiPro menargetkan perusahaan dengan 50–500 karyawan yang masih memakai mesin fingerprint terpisah tanpa integrasi ke sistem penggajian. Masalahnya: rekap kehadiran manual memakan waktu HRD 2–3 hari setiap akhir bulan untuk menyiapkan data gaji.
# MD Document — AbsensiPro
**Versi:** 1.0 | **Tanggal:** 2024-11-15 | **Tim:** Dian, Reza, Amir
## Problem Statement
Tim HRD perusahaan menengah (50–500 karyawan) menghabiskan 2–3 hari
kerja setiap akhir bulan untuk merekap data kehadiran dari mesin
fingerprint ke Excel, lalu menghitung tunjangan, lembur, dan potongan
secara manual sebelum diinput ke sistem penggajian. Kesalahan input
rekap menyebabkan dispute gaji yang merusak kepercayaan karyawan.
## Proposed Solution
Platform web SaaS yang menghubungkan data absensi (fingerprint / face
recognition / QR code selfie) dengan kalkulasi gaji otomatis dan laporan
kehadiran real-time.
Fitur MVP:
1. Import data dari mesin fingerprint format CSV/Excel umum
2. Kalkulasi otomatis: lembur, keterlambatan, izin, cuti
3. Laporan kehadiran per departemen (PDF & Excel)
4. Integrasi export ke format yang diterima software akuntansi umum (Zahir, ACCURATE)
5. Notifikasi karyawan absen lewat WhatsApp
## Target Market
- TAM: 250.000 perusahaan menengah di Indonesia
- SAM: 40.000 yang sudah punya mesin fingerprint namun belum terintegrasi
- SOM (tahun 1): 150 perusahaan (B2B sales)
Persona: Bu Sari, HRD Manager, 35 tahun, PT manufaktur Bekasi 180 karyawan.
Frustrasi utama: lembur manual sering ada complain dari supervisor.
## Revenue Model
B2B Subscription: Rp 25.000/karyawan/bulan, minimum 50 karyawan
= Rp 1.250.000/bulan/perusahaan
Proyeksi M-12: 100 klien = Rp 125 juta/bulan ARR Rp 1,5 miliar
Setup fee: Rp 2.000.000 per implementasi (onboarding + training)
Studi Kasus 3 — JasaKita: Marketplace Jasa Rumah Tangga
JasaKita menghubungkan pemilik rumah di kota besar dengan penyedia jasa rumah tangga terverifikasi (tukang, cleaning service, AC, dsb.) yang selama ini hanya bisa ditemukan melalui rekomendasi mulut ke mulut atau tukang keliling yang sulit dilacak kualitasnya.
# MD Document — JasaKita
**Versi:** 1.0 | **Tanggal:** 2024-12-01 | **Tim:** Fajar, Lina
## Problem Statement
Pemilik rumah di kota besar (Jakarta, Surabaya, Medan) kesulitan menemukan
penyedia jasa rumah tangga terpercaya dalam waktu singkat. Pencarian lewat
tetangga atau grup WA tidak terstruktur, penyedia tidak punya portofolio,
dan tidak ada mekanisme garansi jika pekerjaan tidak memuaskan.
## Proposed Solution
Marketplace hyperlocal yang menghubungkan homeowner dengan mitra jasa
terverifikasi, lengkap dengan rating, portofolio foto, harga transparan,
dan jaminan uang kembali 24 jam.
Fitur MVP:
1. Booking jasa 3 kategori awal: tukang bangunan, cleaning service, AC
2. Profil mitra: foto KTP, foto kerja, rating bintang dari klien sebelumnya
3. Pembayaran via virtual account (Midtrans)
4. Chat in-app antara klien dan mitra
5. Sistem review wajib setelah layanan selesai
## Target Market
- TAM: 12 juta rumah tangga kelas menengah kota besar Indonesia
- SAM: 3 juta di Jabodetabek yang aktif mencari jasa lewat digital
- SOM (tahun 1): 10.000 booking terfasilitasi
## Revenue Model
Marketplace fee: 15% dari nilai setiap transaksi
Average order value: Rp 350.000
Proyeksi M-12: 1.000 booking/bulan x Rp 52.500 fee = Rp 52,5 juta/bulan
Listing premium mitra: Rp 99.000/bulan untuk posisi teratas
Template MD Document Siap Pakai
Gunakan template berikut sebagai titik awal. Isi setiap bagian secara berurutan. Jangan tinggalkan bagian kosong; jika Anda tidak tahu jawabannya, itu adalah prioritas riset berikutnya.
# MD Document — [Nama Produk]
**Versi:** 1.0 | **Tanggal:** YYYY-MM-DD | **Tim:** [Nama 1, Nama 2]
---
## 1. Problem Statement
[1 paragraf, 3–5 kalimat, dengan persona + frekuensi + konsekuensi nyata]
## 2. Proposed Solution
**Value proposition (1 kalimat):**
[Produk kami adalah ... yang membantu ... sehingga ...]
**Fitur MVP (3–5 saja):**
1. [Fitur 1 — wajib ada]
2. [Fitur 2 — wajib ada]
3. [Fitur 3 — wajib ada]
## 3. Target Market
- **TAM:** [N juta pengguna potensial] — Sumber: [BPS / Statista / Asosiasi]
- **SAM:** [N ribu yang bisa dijangkau dalam 12 bulan]
- **SOM (tahun 1):** [Target realistis dengan tim dan anggaran ini]
**Persona Primer:**
Nama: [Fiktif tapi spesifik]
Usia: [Rentang]
Profesi: [Spesifik]
Perangkat: [Android entry-level / iOS / Desktop]
Kutipan wawancara: "[Kata-kata asli dari wawancara validasi]"
## 4. Competitors Analysis
| Kompetitor | Jenis | Kelemahan Utama | Harga |
|------------|-------|-----------------|-------|
| [Nama 1] | Langsung | [Gap konkret] | [Rp/bln] |
| [Nama 2] | Langsung | [Gap konkret] | [Rp/bln] |
| [Nama 3] | Tidak langsung | [Gap konkret] | Gratis |
**Posisi diferensiasi kami:**
[Kami berbeda karena ... yang tidak dimiliki kompetitor manapun]
## 5. Revenue Model
**Model utama:** [Subscription / Marketplace fee / B2B license / Freemium]
**Harga:** [Rp X per Y]
**Proyeksi konservatif M-6:**
- Target pengguna berbayar: [N]
- MRR: Rp [N x harga]
- Biaya operasional: Rp [server + tim]
- Gross profit: Rp [selisih]
## 6. Status Validasi
- Wawancara dilakukan: [N orang, tanggal]
- Landing page test: [N klik, N email, konversi X%]
- Pre-order: [N orang, total Rp X]
- Keputusan: [ ] Lanjut ke MVP [ ] Perlu validasi lebih [ ] Pivot [ ] Hentikan
Cara Presentasi MD Document ke Stakeholder dan Investor
MD Document bukan hanya untuk tim internal. Ketika Anda membutuhkan co-founder, investor angel, atau sponsor perusahaan, dokumen ini adalah alat komunikasi pertama Anda. Beberapa prinsip presentasi:
Ringkas ke 5 slide. Dari MD Document, ekstrak 5 slide: Problem (1 slide dengan angka dampak), Solution (1 slide dengan screenshot atau mockup), Market Size (1 slide dengan TAM/SAM/SOM + persona), Traction (1 slide dengan hasil validasi nyata — jangan kosong), dan Ask (apa yang Anda butuhkan: investasi berapa, kemitraan apa, atau umpan balik spesifik apa). Investor melihat ratusan pitch; 5 slide yang padat lebih dihormati daripada 20 slide yang mengulang hal yang sama.
Mulai dengan masalah, bukan produk. Kesalahan paling umum: slide pertama adalah nama produk dan logo yang keren. Mulailah dengan masalah yang sangat konkret dan angka dampaknya. Buat audience merasa frustrasi membayangkan masalah itu sebelum Anda tunjukkan solusinya.
Tips. Cetak MD Document menjadi satu halaman A4 dan bawa ke setiap pertemuan awal. Dokumen fisik menunjukkan Anda serius dan sudah berpikir sistematis. Investor dan mitra bisnis lebih mudah memberi umpan balik konkret pada dokumen yang bisa mereka coret-coret daripada presentasi slide yang cepat berlalu.
Perencanaan MVP
Setiap developer pernah jatuh dalam perangkap yang sama: mulai membangun sebuah “MVP” yang ternyata punya 40 fitur, memakan 8 bulan, dan habis sebelum satu pengguna nyata pernah mencobanya. Masalahnya bukan pada kemampuan teknis, melainkan pada definisi. MVP yang sesungguhnya adalah bukan produk yang minimal secara fitur, melainkan produk yang paling cepat membuktikan atau mematahkan hipotesis bisnis utama Anda. Ini perbedaan yang sangat fundamental.
Membedakan MVP, Prototype, dan PoC
Ketiga istilah ini sering dipakai bergantian, padahal memiliki tujuan, audiens, dan definisi “selesai” yang berbeda. Memahami perbedaannya menentukan berapa lama Anda harus membangun sebelum menunjukkan ke dunia.
| Dimensi | PoC (Proof of Concept) | Prototype | MVP |
|---|---|---|---|
| Tujuan | Membuktikan teknis mungkin | Mengujicoba alur penggunaan / desain | Memvalidasi asumsi bisnis dengan pengguna nyata |
| Audiens | Tim internal, CTO | Desainer, tim produk, pengguna beta | Pengguna akhir yang membayar atau berpotensi membayar |
| Kualitas Kode | Bisa asal jalan (throwaway) | Tidak wajib fungsional penuh (bisa klikable mockup) | Harus production-ready untuk inti fitur utama |
| Deployment | Lokal / demo internal | Staging / Figma / InVision | Live server, domain nyata |
| Waktu Umum | 1–5 hari | 1–3 minggu | 4–12 minggu |
| “Selesai” artinya | Kode berjalan, output terbukti | Alur bisa diklik, feedback dikumpulkan | Pengguna berhasil melakukan tugas utama tanpa bantuan tim |
| Contoh | Tes integrasi API Payment Gateway baru | Figma clickable untuk alur onboarding | Aplikasi kasir: input transaksi + laporan harian berjalan |
Inti. PoC menjawab “apakah ini bisa dibangun?” Prototype menjawab “apakah ini mudah dipakai?” MVP menjawab “apakah orang mau membayar untuk ini?” Jangan campurkan ketiganya; selesaikan satu pertanyaan sebelum lanjut ke berikutnya.
Menentukan Scope MVP: Fitur Inti Minimum
Cara paling praktis menentukan scope MVP adalah dengan pertanyaan eliminasi: “Jika fitur ini dihilangkan, apakah pengguna masih bisa mendapatkan nilai inti dari produk?” Jika jawabannya ya, fitur itu bukan bagian MVP. Ulangi untuk setiap fitur yang Anda usulkan.
Untuk aplikasi kasir KasirKu, prosesnya seperti ini:
- Input transaksi → dihapus → tidak ada yang bisa dicatat → WAJIB
- Laporan harian otomatis → dihapus → nilai utama (visibilitas pemilik) hilang → WAJIB
- Alert stok kritis → dihapus → masih ada laporan harian → OPSIONAL v2
- Multi-outlet → dihapus → 90% pengguna MVP hanya punya 1 outlet → HAPUS dari MVP
- Manajemen karyawan → dihapus → sama sekali tidak terkait masalah utama → HAPUS dari MVP
Hasilnya: MVP hanya butuh 2 fitur wajib. Bukan 10, bukan 5. Dua. Ini yang memungkinkan MVP selesai dalam 6 minggu, bukan 6 bulan.
Prioritasi Fitur: MoSCoW, RICE, dan Impact/Effort
Ketika Anda sudah punya daftar calon fitur yang lebih dari 5, Anda perlu framework prioritasi yang objektif. Tiga yang paling sering dipakai:
MoSCoW membagi fitur ke dalam 4 kategori: Must Have (wajib untuk MVP berfungsi), Should Have (penting tapi bisa ditunda 1 iterasi), Could Have (nice-to-have jika ada waktu), Won't Have (untuk versi ini — bukan tidak pernah). Aturan: total Must Have tidak boleh melebihi 60% kapasitas sprint Anda. Jika melebihi, pindahkan beberapa ke Should Have.
RICE Score memberikan angka objektif untuk setiap fitur berdasarkan empat variabel:
RICE Score = (Reach × Impact × Confidence) / Effort
Definisi variabel:
- Reach : Berapa pengguna terdampak dalam 1 bulan? (angka absolut)
- Impact : Seberapa besar dampak ke tujuan utama? (skala 0.25 / 0.5 / 1 / 2 / 3)
- Confidence: Seberapa yakin estimasi ini? (100% = sangat yakin, 50% = tebakan)
- Effort : Berapa person-week dibutuhkan? (mis. 2 orang × 1 minggu = 2)
Contoh perhitungan untuk fitur "Laporan WhatsApp Otomatis":
- Reach = 500 (semua pengguna aktif menerima laporan)
- Impact = 3 (dampak langsung ke value proposition utama)
- Confidence= 80% = 0.8
- Effort = 2 (1 orang × 2 minggu)
RICE Score = (500 × 3 × 0.8) / 2 = 1200 / 2 = 600
Contoh untuk fitur "Multi-bahasa":
- Reach = 50 (hanya pengguna yang tidak bisa Bahasa Indonesia)
- Impact = 0.5 (dampak kecil ke nilai inti)
- Confidence= 60% = 0.6
- Effort = 4 (integrasi i18n cukup kompleks)
RICE Score = (50 × 0.5 × 0.6) / 4 = 15 / 4 = 3.75
Kesimpulan: Laporan WhatsApp (600) jauh lebih prioritas
daripada Multi-bahasa (3.75). Fokus ke yang skor tertinggi.
Impact/Effort Matrix adalah versi visual dan lebih cepat dari RICE. Buat sumbu X (Effort: Low ke High) dan sumbu Y (Impact: Low ke High). Tempatkan setiap fitur di kuadrannya:
| Effort Rendah | Effort Tinggi | |
|---|---|---|
| Impact Tinggi | Quick Wins — Kerjakan Sekarang | Big Bets — Jadwalkan dengan hati-hati |
| Impact Rendah | Fill-ins — Jika ada waktu luang | Time Wasters — Hindari sepenuhnya |
Tips. Gunakan RICE untuk backlog fitur yang sudah punya estimasi effort. Gunakan Impact/Effort Matrix untuk sesi brainstorming cepat dengan tim (30 menit, sticky notes). Gunakan MoSCoW untuk presentasi ke stakeholder non-teknis karena bahasa kategorinya lebih mudah dipahami tanpa konteks numerik.
Timeline MVP 1–3 Bulan
Timeline berikut adalah panduan untuk tim 2 orang (1 backend, 1 frontend, atau keduanya full-stack) membangun MVP yang layak uji ke pengguna nyata. Sesuaikan dengan kapasitas tim Anda.
Fase 1 (Minggu 1–2): Setup & Design. Buat repository, setup CI/CD dasar (minimal GitHub Actions untuk auto-deploy ke staging), desain skema database awal, dan selesaikan wireframe interaktif untuk alur pengguna utama. Jangan mulai coding fitur sebelum wireframe disetujui semua anggota tim. Ini menghemat waktu refactor yang sangat besar.
Fase 2 (Minggu 3–6): Core Build. Bangun fitur Must Have satu per satu, dengan urutan: autentikasi (login/register), fitur inti nomor 1, fitur inti nomor 2, integrasi eksternal yang wajib (payment gateway, WhatsApp API, dsb.). Jalankan unit test minimal untuk setiap endpoint API. Deploy ke staging setiap akhir minggu.
Fase 3 (Minggu 7–8): Polish & Beta. Hentikan penambahan fitur baru. Fokus eksklusif pada: bug fixing dari session pengujian tim, peningkatan pesan error yang ramah pengguna, dan onboarding 10 beta tester nyata. Duduk bersama mereka (atau amati rekaman layar mereka) saat pertama kali memakai aplikasi. Catat setiap kebingungan mereka.
Fase 4 (Minggu 9–12): Launch & Iterate. Rilis ke publik dengan batasan kuota jika diperlukan (waitlist, invite-only). Pasang alat analitik (Mixpanel, PostHog, atau Google Analytics 4). Mulai siklus feedback dua mingguan: kumpulkan feedback, prioritaskan perbaikan, rilis patch. Jangan tambah fitur besar sampai retention mingguan di atas 30%.
Awas. Minggu 5–6 adalah fase paling berbahaya. Tim mulai melihat aplikasi “hampir selesai” dan tergoda menambahkan fitur-fitur kecil yang “tidak memakan waktu lama.” Kumpulan fitur kecil ini adalah penyebab MVP yang seharusnya selesai 2 bulan menjadi 8 bulan. Gunakan issue tracker (Linear, Jira, atau Notion) dan wajibkan semua fitur baru masuk ke backlog, bukan langsung dikerjakan.
Brand Identity & Naming
Nama produk adalah keputusan bisnis pertama yang akan Anda buat dan salah satu yang paling sulit untuk diubah kemudian. Nama yang buruk membebani setiap kampanye pemasaran, setiap email dingin, dan setiap percakapan word-of-mouth. Nama yang baik bekerja sendiri: mudah diucapkan, mudah dieja, mudah dicari di Google, dan langsung menyampaikan positioning. Namun di luar nama, brand identity yang konsisten adalah yang membedakan produk amatur dari produk profesional bahkan sebelum pengguna mencoba fitur pertamanya.
Brainstorming Nama Produk
Penamaan produk bukan pekerjaan intuitif yang selesai dalam 10 menit. Ia adalah proses eliminasi terstruktur. Mulai dengan volume, bukan kualitas: targetkan 50–100 nama kandidat sebelum mulai mengeliminasi. Gunakan enam pendekatan berikut secara paralel:
1. Nama Deskriptif. Langsung mendeskripsikan fungsi: KasirKu, AbsensiPro, TokoOnline. Kelebihan: langsung dipahami. Kekurangan: sulit membedakan dari kompetitor, dan risiko konflik merek dagang lebih tinggi karena kata-kata generik susah didaftarkan sebagai merek.
2. Nama Metaforis / Evocatif. Meminjam makna dari objek atau konsep lain: Tokopedia (toko + ensiklopedia), Gojek (ojek + go), Ruangguru (ruang + guru). Kelebihan: memorable dan bisa membawa emosi positif. Kekurangan: perlu penjelasan awal kepada pengguna baru.
3. Nama Singkatan atau Akronim. BCA (Bank Central Asia), GoTo, OJK. Kelebihan: pendek dan brandable. Kekurangan: tidak mengandung makna sendiri dan membutuhkan investasi besar untuk membangun asosiasi merek.
4. Nama Gabungan (Portmanteau). Menggabungkan dua kata: Airbnb (Air Bed and Breakfast), Spotify (spot + identify), Snapchat (snap + chat). Dalam konteks lokal: Warpin (warung + online), Jagalink (jaga + link). Kelebihan: unik dan mudah didaftarkan merek dagang. Kekurangan: perlu uji kejelasan pelafalan di target pasar.
5. Nama dari Bahasa Daerah atau Sanskerta. Indonesia kaya kosakata yang belum banyak dipakai sebagai nama produk: Singa (kekuatan), Wahyu (pencerahan), Satria (ksatria), Reka (mencipta dalam Sunda), Cipta (Jawa: berkreasi). Kelebihan: diferensiasi tinggi, makna kaya, relevan secara budaya. Kekurangan: perlu uji pelafalan di berbagai daerah.
6. Nama Personal atau Founder-led. Menggunakan nama pendiri (Samsung dari Lee Byung-chul, nama marga Jepang-nya). Atau nama yang terasa seperti nama orang: Asisten, Dira, Rama. Kelebihan: berasa humanis dan personal. Kekurangan: scalability saat brand berkembang jauh dari pendirinya.
Filter Nama: 7 Kriteria Eliminasi
Setelah mengumpulkan 50+ kandidat, jalankan setiap nama melalui filter ini. Nama yang lolos semua filter adalah kandidat kuat.
| No | Kriteria | Cara Uji | Catatan |
|---|---|---|---|
| 1 | Mudah dieja saat didengar | Ucapkan nama ke 5 orang berbeda, minta mereka menuliskannya | Gagal jika >1 orang salah eja |
| 2 | Mudah diucapkan saat dibaca | Tunjukkan tulisan nama ke 5 orang berbeda | Gagal jika ada kebingungan pelafalan |
| 3 | Domain .com atau .id tersedia |
Cek di Whois.domaintools.com atau namecheap.com | Prioritas .id untuk pasar Indonesia |
| 4 | Handle media sosial tersedia | Cek @namaanda di Instagram, TikTok, Twitter/X | Gunakan namecheckr.com untuk cek serentak |
| 5 | Tidak ada konflik merek dagang | Cari di pdki.kemenkumham.go.id dan Google Trademark | Kategori kelas 42 (software) paling relevan |
| 6 | Tidak bermakna negatif di bahasa lain | Terjemahkan ke Inggris, Mandarin, Arab | Penting jika ada rencana ekspansi regional |
| 7 | Membangkitkan asosiasi positif | Tanya 10 orang: “nama ini membuat kamu memikirkan apa?” | Asosiasi harus relevan dengan kategori produk |
Tagline dan Brand Voice
Tagline bukan slogan iklan. Tagline adalah ekspresi positioning dalam satu kalimat yang bertahan bertahun-tahun. Tagline terbaik memiliki tiga karakter: spesifik (bukan generik seperti “solusi terbaik Anda”), berorientasi manfaat pengguna (bukan fitur produk), dan mudah diingat dalam satu kali baca.
Contoh tagline yang efektif dalam konteks produk Indonesia:
- KasirKu: “Warung Anda, Laporan Real-time Untuk Anda”
- AbsensiPro: “Rekap Kehadiran Selesai Sebelum Makan Siang”
- JasaKita: “Tukang Terpercaya, Satu Klik”
Brand voice adalah kepribadian produk dalam komunikasi. Definisikan brand voice dalam tiga dimensi:
- Tone spectrum: Formal ↔ Kasual. Produk B2B enterprise biasanya lebih formal; produk konsumen millennial/Gen-Z lebih kasual.
- Expertise level: Teknis ↔ Non-teknis. Bagaimana Anda berbicara tentang produk Anda kepada audiens awam?
- Emosi dominan: Pilih dua: Empati, Optimisme, Keberanian, Ketenangan, Kegembiraan.
Tuliskan brand voice dalam satu kartu referensi singkat yang bisa diakses semua anggota tim (developer, designer, CS, marketing). Contoh: “KasirKu bicara seperti teman yang paham bisnis warung makan: hangat, langsung pada intinya, tidak sok formal, selalu berorientasi pada solusi praktis. Kami tidak menggunakan jargon IT. Kami menggunakan kata-kata yang pemilik warung pakai sehari-hari.”
Palet Warna dan Psikologi Warna
Warna adalah elemen komunikasi non-verbal paling kuat dalam desain produk. Penelitian psikologi warna menunjukkan bahwa pengguna membentuk kesan pertama tentang sebuah produk dalam 90 detik, dan 60–90% dari kesan itu ditentukan oleh warna saja. Pilih warna berdasarkan makna yang ingin Anda sampaikan, bukan preferensi pribadi Anda.
| Warna | Makna Utama | Cocok untuk Produk | Contoh Brand | Hindari jika |
|---|---|---|---|---|
| Biru Tua (#1e3a8a) | Kepercayaan, stabilitas, profesional | Fintech, B2B SaaS, kesehatan | BCA, BNI, LinkedIn, Jira | Target Gen-Z yang ingin produk energik & fun |
| Hijau (#16a34a) | Pertumbuhan, alam, keberhasilan, uang | Keuangan, pertanian, health-tech, keberlanjutan | Grab, Gojek (awal), WhatsApp | Produk yang ingin tampil premium/luxury |
| Oranye/Amber (#f59e0b) | Energi, aksesibilitas, kehangatan, urgensi ringan | Marketplace, food-tech, edukasi | Shopee, Tokopedia (lama), Amazon | Produk yang butuh kesan formal/serius penuh |
| Merah (#dc2626) | Aksi cepat, urgensi, gairah, promosi | E-commerce flash sale, food delivery | Lazada, YouTube, GoFood | Fintech/kesehatan (berasosiasi dengan bahaya) |
| Ungu (#7c3aed) | Kreativitas, inovasi, premium, spiritual | AI/tech produk, pendidikan premium, entertainment | Twitch, FigJam, Notion | Produk UMKM skala kecil (bisa terasa terlalu niche) |
| Putih + Abu Netral | Kebersihan, kesederhanaan, modernitas | Semua kategori sebagai base/background | Apple, Google, Linear | Tidak boleh menjadi warna tunggal (perlu accent) |
Untuk produk MVP, gunakan sistem tiga warna saja: Primary (warna utama brand, untuk CTA button, header, elemen aktif), Accent (warna kontras untuk highlight, badge, notifikasi), dan Neutral (abu-abu atau off-white untuk background, teks, border). Lebih dari tiga warna di tahap MVP membuat desain terasa tidak konsisten tanpa designer berpengalaman yang mengaturnya.
Prinsip Desain Logo untuk Produk Digital
Logo MVP tidak harus sempurna, tetapi harus berfungsi. Artinya: terbaca pada ukuran 16×16 px (favicon), terbaca dalam versi hitam-putih (cetak), dan tidak bergantung pada detail yang hilang saat dikecilkan. Lima prinsip dasar:
1. Simplisitas ekstrem. Logo terbaik bisa dijelaskan dalam satu kalimat deskripsi visual: “lingkaran merah dengan tanda minus putih di tengah.” Jika deskripsinya membutuhkan tiga kalimat, logo Anda terlalu kompleks.
2. Scalability. Test logo Anda di lima ukuran: 512px (App Store/Play Store), 192px (PWA icon), 64px (browser tab), 32px (favicon), 16px (favicon minimum). Jika ada elemen yang hilang atau tidak terbaca di ukuran kecil, sederhanakan.
3. Format vektor wajib. Selalu simpan logo dalam SVG (Scalable Vector Graphics). PNG untuk export, tetapi SVG untuk master. Gunakan Inkscape (gratis) atau Figma untuk membuat dan mengedit SVG.
4. Satu berat tipografi. Jika logo Anda menggunakan teks (wordmark), gunakan satu font saja. Hindari efek gradien atau shadow pada tipografi logo; keduanya mengurangi keterbacaan di ukuran kecil.
5. Konsisten dengan brand voice. Logo untuk produk kasir UMKM yang “hangat dan ramah” harus memiliki sudut yang lebih rounded (radius tinggi) dan menggunakan warna yang lebih warm (hijau atau oranye), bukan sudut tajam dan warna dingin yang terasa corporate.
Tips. Untuk MVP, gunakan tools gratis yang sudah terbukti: Figma (free tier) untuk desain logo berbasis vektor, Canva untuk variasi social media, dan Favicon.io untuk menghasilkan set favicon dari satu file PNG. Jangan habiskan anggaran untuk logo sempurna sebelum produk Anda validated di pasar.
Brand Guidelines Minimal: Token Desain
Brand guidelines tidak harus berupa PDF 60 halaman. Untuk MVP, cukup satu file yang mendokumentasikan token desain: warna, tipografi, ukuran spacing, dan radius border. File ini menjadi kontrak visual antara designer dan developer.
/* ============================================================
BRAND DESIGN TOKENS — [Nama Produk]
Versi: 1.0 | Update: YYYY-MM-DD
============================================================ */
:root {
/* === WARNA UTAMA === */
--color-primary-900: #0c1b3d; /* brand-dark: header, footer */
--color-primary-700: #1e3a8a; /* brand: CTA button, link aktif */
--color-primary-500: #3b5bdb; /* brand-light: hover state */
--color-primary-100: #dbeafe; /* brand-tint: background highlight */
/* === WARNA AKSEN === */
--color-accent-500: #f59e0b; /* amber: badge, notif, CTA sekunder */
--color-accent-100: #fef9c3; /* amber-light: background warning */
/* === WARNA STATUS === */
--color-success-500: #16a34a; /* hijau: success state, badge aktif */
--color-success-100: #dcfce7; /* hijau-light: background sukses */
--color-danger-500: #dc2626; /* merah: error, delete, alert kritis */
--color-danger-100: #fee2e2; /* merah-light: background error */
--color-warning-500: #d97706; /* oranye gelap: peringatan */
--color-warning-100: #fff7ed; /* oranye-light: background warning */
/* === WARNA NETRAL === */
--color-neutral-900: #111827; /* teks utama */
--color-neutral-700: #374151; /* teks sekunder */
--color-neutral-500: #6b7280; /* teks placeholder, caption */
--color-neutral-300: #d1d5db; /* border, divider */
--color-neutral-100: #f3f4f6; /* background page, table header */
--color-neutral-50: #f9fafb; /* background input, card */
--color-white: #ffffff;
/* === TIPOGRAFI === */
--font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-family-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
--font-size-xs: 0.75rem; /* 12px — caption, label kecil */
--font-size-sm: 0.875rem; /* 14px — body kecil, tabel */
--font-size-base: 1rem; /* 16px — body utama */
--font-size-lg: 1.125rem; /* 18px — subheading, lead */
--font-size-xl: 1.25rem; /* 20px — h4 */
--font-size-2xl: 1.5rem; /* 24px — h3 */
--font-size-3xl: 1.875rem; /* 30px — h2 */
--font-size-4xl: 2.25rem; /* 36px — h1 mobile */
--font-size-5xl: 3rem; /* 48px — h1 desktop */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold:600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-snug: 1.375;
--line-height-normal: 1.5;
--line-height-relaxed:1.625;
/* === SPACING (skala 4px) === */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
/* === BORDER RADIUS === */
--radius-sm: 0.25rem; /* 4px — tombol kecil, badge */
--radius-md: 0.5rem; /* 8px — card, input */
--radius-lg: 0.75rem; /* 12px — modal, sidebar */
--radius-xl: 1rem; /* 16px — panel besar */
--radius-full: 9999px; /* pill — tag, chip, toggle */
/* === SHADOW === */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10);
/* === LAYOUT === */
--sidebar-width: 240px;
--sidebar-width-collapsed: 64px;
--topbar-height: 56px;
--content-max-width: 1280px;
--modal-width-sm: 400px;
--modal-width-md: 560px;
--slide-panel-width: 480px;
}
File token di atas bisa langsung dipakai di proyek manapun. Simpan sebagai tokens.css dan import di file CSS utama Anda. Untuk proyek yang menggunakan Tailwind CSS, konversi token ini ke konfigurasi tailwind.config.js:
// tailwind.config.js — Integrasi Brand Design Tokens
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./pages/**/*.{js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
brand: {
50: '#eef2fb',
100: '#dbeafe',
500: '#3b5bdb',
700: '#1e3a8a',
900: '#0c1b3d',
},
accent: {
100: '#fef9c3',
500: '#f59e0b',
700: '#b45309',
},
},
fontFamily: {
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'Courier New', 'monospace'],
},
spacing: {
'18': '4.5rem',
'22': '5.5rem',
'88': '22rem', // sidebar width default
},
width: {
sidebar: '240px',
'sidebar-sm': '64px',
'slide-panel': '480px',
},
borderRadius: {
'xl': '0.75rem',
'2xl': '1rem',
},
},
},
plugins: [],
}
Contoh. Tim KasirKu memilih palet: Primary #16a34a (hijau — uang, pertumbuhan, ramah UMKM), Accent #f59e0b (amber — hangat, aksesibel), Neutral abu-abu standar. Font: Inter (tersedia gratis di Google Fonts, keterbacaan tinggi di layar resolusi rendah Android entry-level). Radius: 8px untuk card dan input, 9999px untuk badge status. Hasilnya: UI yang terasa modern tanpa harus memiliki designer berpengalaman.
Checklist Brand Identity Sebelum Launch
Sebelum produk Anda muncul di depan pengguna pertama, pastikan semua elemen brand berikut sudah tersedia dan konsisten:
- Nama produk lolos 7 kriteria eliminasi (domain, merek dagang, pelafalan)
- Tagline final terdokumentasi dan disetujui tim
- Logo dalam format SVG (untuk versi terang dan gelap)
- Favicon tersedia dalam ukuran 16px, 32px, 192px, dan 512px
- File design token (
tokens.cssatautailwind.config.js) terpasang di repositori - Brand voice terdokumentasi dalam satu paragraf referensi
- Handle sosial media terdaftar (@namaprodukyou di Instagram, TikTok, Twitter/X)
- Domain
.idatau.comsudah dibeli dan diarahkan ke aplikasi/landing page
Inti. Brand identity bukan tentang membuatnya sempurna; ini tentang membuatnya konsisten. Satu warna, satu font, satu voice yang dipakai secara konsisten selama 6 bulan lebih berkesan daripada elemen visual yang indah tapi berubah-ubah di setiap touchpoint. Mulai sederhana, terapkan secara konsisten, dan perbaiki berdasarkan feedback pengguna nyata.
Arsitektur & Infrastruktur
Memilih Arsitektur Aplikasi
Sebelum menulis satu baris kode pun, Anda perlu memutuskan bagaimana aplikasi Anda akan disusun. Keputusan ini tidak terlihat di antarmuka pengguna, tidak ada di slide pitch, dan tidak tercantum dalam spesifikasi fitur — namun ia menentukan kecepatan tim Anda dalam enam bulan pertama, biaya server setahun ke depan, dan apakah refaktor besar di bulan ke-18 bisa dihindari atau tidak. Arsitektur adalah rangka tersembunyi yang menopang segalanya.
Ada empat pola arsitektur yang paling sering digunakan untuk aplikasi web modern: Modular Monolith, Microservices, Serverless, dan Event-Driven Architecture. Keempatnya bukan kompetitor mutlak — banyak sistem produksi menggabungkan dua atau tiga di antaranya. Namun untuk MVP, pilihan awal menentukan kecepatan iterasi, sehingga memilih yang salah bisa memperlambat Anda jauh sebelum ada pengguna nyata.
Modular Monolith
Modular Monolith adalah satu proses tunggal yang di-deploy sebagai satu unit, tetapi di dalamnya kode diorganisasi dalam modul-modul terpisah dengan batas yang jelas. Ia berbeda dari monolith klasik (yang hanya satu tumpukan kode tanpa batas) dan dari microservices (yang memisahkan proses di jaringan). Modular Monolith merupakan titik tengah yang optimal.
Kelebihan Modular Monolith: Deployment satu langkah, tidak ada overhead jaringan antar-modul, debugging lebih mudah karena stack trace tidak tersebar di beberapa layanan, dan pengembang baru dapat memahami seluruh sistem dari satu repositori. Untuk tim kecil (2–8 orang), ini hampir selalu pilihan terbaik.
Kekurangan Modular Monolith: Seluruh aplikasi harus di-deploy ulang setiap ada perubahan kecil, tidak ada isolasi kegagalan di level proses (satu bug yang mengonsumsi memori bisa menjatuhkan seluruh layanan), dan scaling horizontal harus dilakukan untuk seluruh aplikasi meskipun hanya satu modul yang butuh lebih banyak kapasitas.
Rumus. Untuk MVP dengan tim ≤10 orang dan pengguna <50.000 aktif bulanan, Modular Monolith adalah pilihan default. Jangan berpindah ke microservices sampai Anda menemukan bottleneck nyata yang bisa diukur.
Microservices
Microservices memecah aplikasi menjadi layanan-layanan kecil yang masing-masing berjalan sebagai proses terpisah, berkomunikasi lewat API HTTP atau pesan broker (Kafka, RabbitMQ). Setiap layanan punya database sendiri, bisa di-deploy secara independen, dan bisa ditulis dalam bahasa yang berbeda.
Kapan pakai Microservices: Tim Anda sudah besar (>20 developer) dan koordinasi antar-tim menjadi hambatan; ada modul dengan kebutuhan scaling yang sangat berbeda (misalnya layanan video encoding vs. layanan autentikasi); atau regulasi mengharuskan isolasi data yang ketat antar-domain.
Kapan jangan pakai Microservices: MVP, startup fase awal, tim kecil, atau ketika belum ada pengguna nyata. Overhead operasional microservices — service discovery, distributed tracing, eventual consistency, saga pattern untuk transaksi terdistribusi — bisa menghabiskan 40–60% waktu developer hanya untuk infrastruktur, bukan fitur bisnis.
Awas. Microservices adalah solusi untuk masalah organisasi (tim besar yang perlu bekerja independen) bukan masalah teknis semata. Banyak startup membayar biaya microservices sebelum mendapat manfaatnya.
Serverless
Serverless (Function-as-a-Service) menjalankan kode dalam fungsi-fungsi stateless yang dipanggil atas dasar permintaan, dengan scaling otomatis ke nol ketika tidak ada permintaan. AWS Lambda, Vercel Functions, Cloudflare Workers, dan Google Cloud Functions adalah contoh utamanya.
Kapan cocok: API ringan dengan traffic tidak merata (siang ramai, malam sepi), webhook handler, cron job sederhana, dan aplikasi yang perlu skala dari nol ke jutaan permintaan tanpa konfigurasi. Biaya dihitung per-invokasi, sehingga sangat ekonomis untuk traffic rendah.
Keterbatasan: Cold start (latensi pertama setelah idle), batas durasi eksekusi (umumnya 15 menit), state tidak persisten antar-invokasi, dan debugging yang lebih sulit dibanding server tradisional. Tidak cocok untuk operasi panjang seperti video processing atau koneksi WebSocket jangka panjang.
Event-Driven Architecture
Event-Driven Architecture (EDA) adalah pola di mana komponen berkomunikasi melalui peristiwa (events) yang dipublikasikan ke event bus atau message broker. Producer mengirim event tanpa tahu siapa yang akan menerimanya; consumer berlangganan event yang relevan dan memrosesnya secara asinkron.
EDA sangat berguna untuk decoupling: ketika pengguna mendaftar, sistem mengirim event user.registered, lalu layanan email, layanan onboarding, dan layanan analitik masing-masing merespons secara independen. Tidak ada panggilan sinkron yang berrantai dan rawan timeout.
Kapan pakai EDA: Alur kerja yang melibatkan banyak domain (pendaftaran, pembayaran, notifikasi); kebutuhan audit log lengkap; atau integrasi dengan sistem pihak ketiga yang harus toleran terhadap kegagalan sementara.
Tabel Perbandingan Arsitektur
| Dimensi | Modular Monolith | Microservices | Serverless | Event-Driven |
|---|---|---|---|---|
| Kompleksitas awal | Rendah | Sangat tinggi | Rendah–Sedang | Sedang |
| Kecepatan iterasi MVP | Sangat cepat | Lambat | Cepat | Sedang |
| Biaya infra (traffic rendah) | Tetap (VPS) | Tinggi (N server) | Sangat rendah | Sedang |
| Debugging | Mudah | Sulit (distributed) | Sedang | Sulit (async) |
| Scaling per-komponen | Tidak | Ya | Otomatis | Ya (consumer) |
| Isolasi kegagalan | Rendah | Tinggi | Tinggi | Sedang |
| Ukuran tim ideal | 2–10 | >20 | 1–15 | 5–30 |
| Cocok untuk MVP | Ya (pilihan utama) | Tidak | Tergantung | Sebagian |
Bab berikutnya membahas Modular Monolith secara mendalam — termasuk struktur folder, batas modul, dan kapan saatnya migrasi ke microservices.
RUMUS WAJIB: Modular Monolith
Modular Monolith bukan kompromi malas antara “sistem yang benar” dan “monolith warisan yang buruk.” Ia adalah pilihan sadar yang didasarkan pada satu prinsip: kompleksitas yang Anda perlukan sekarang harus setara dengan masalah yang Anda selesaikan sekarang — tidak lebih. Untuk tim yang sedang memvalidasi ide di depan pengguna nyata, kompleksitas distribusi jaringan adalah utang yang belum jatuh tempo.
Mengapa Modular Monolith untuk MVP
Tiga alasan praktis yang sering diabaikan developer muda:
1. Satu proses, satu deploy. Ketika Anda memperbaiki bug atau merilis fitur, Anda menjalankan satu perintah: git push ke pipeline CI/CD, dan satu artifact (Docker image atau binary) naik ke server. Tidak ada koordinasi versi antar-layanan, tidak ada masalah “layanan A butuh versi 2 dari layanan B yang belum naik.”
2. Panggilan antar-modul adalah function call, bukan HTTP request. Ketika modul Order perlu data User, ia memanggil userService.findById(userId) — bukan fetch('http://user-service:3001/users/' + userId). Tidak ada network hop, tidak ada timeout, tidak ada serialisasi JSON untuk data internal, dan error propagation jauh lebih deterministik.
3. Refaktor dan trace lebih mudah. IDE bisa melacak semua penggunaan sebuah fungsi lintas modul. Ketika Anda mengganti signature fungsi, TypeScript akan menampilkan semua titik yang perlu diperbarui — tidak ada layanan tersembunyi yang baru gagal di production karena contract API berubah.
Rumus. Mulai dengan Modular Monolith. Pisahkan ke microservices hanya bila Anda sudah mengukur bahwa satu modul menjadi bottleneck yang tidak bisa diselesaikan dengan optimasi database atau caching.
Struktur Folder Modular Monolith
Berikut adalah struktur folder yang direkomendasikan untuk aplikasi Node.js/TypeScript dengan arsitektur Modular Monolith. Prinsip utamanya: setiap domain bisnis punya direktori sendiri dengan sub-direktori yang konsisten.
src/
├── app.ts # Entry point, inisialisasi Express/Fastify
├── config/
│ ├── database.ts # Konfigurasi koneksi DB
│ ├── redis.ts # Konfigurasi Redis
│ └── env.ts # Validasi environment variable (zod)
├── shared/
│ ├── middleware/
│ │ ├── auth.middleware.ts
│ │ ├── rate-limit.ts
│ │ └── error-handler.ts
│ ├── utils/
│ │ ├── pagination.ts
│ │ ├── date.ts
│ │ └── slugify.ts
│ ├── types/
│ │ ├── common.types.ts
│ │ └── api-response.types.ts
│ └── database/
│ ├── base-repository.ts # Abstract CRUD + soft delete
│ └── migrations/ # Knex atau Prisma migrations
├── modules/
│ ├── user/
│ │ ├── user.controller.ts # HTTP handler (thin)
│ │ ├── user.service.ts # Business logic
│ │ ├── user.repository.ts # DB query (panggil base-repo)
│ │ ├── user.schema.ts # Zod validation schema
│ │ ├── user.types.ts # Interface & DTO
│ │ └── user.routes.ts # Express Router
│ ├── order/
│ │ ├── order.controller.ts
│ │ ├── order.service.ts
│ │ ├── order.repository.ts
│ │ ├── order.schema.ts
│ │ ├── order.types.ts
│ │ └── order.routes.ts
│ ├── product/
│ │ └── ... # Pola sama
│ ├── payment/
│ │ └── ...
│ └── notification/
│ └── ...
└── infrastructure/
├── queue/
│ └── bull.setup.ts # BullMQ setup
├── storage/
│ └── s3.client.ts
└── email/
└── nodemailer.client.ts
Domain-Driven Design untuk Monolith
DDD (Domain-Driven Design) sering dianggap sebagai “ilmu microservices,” padahal konsep utamanya — Bounded Context, Aggregate, Repository pattern, Domain Service — sepenuhnya berlaku untuk monolith. Bedanya, di monolith, Bounded Context adalah direktori modul, bukan layanan terpisah.
Bounded Context: Setiap modul (user, order, product) adalah satu Bounded Context. Di dalamnya, kata “produk” boleh berarti berbeda: di modul Order, produk hanya perlu nama dan harga; di modul Warehouse, produk perlu lokasi rak dan stok. Jangan buat satu Product class yang menanggung semua atribut — biarkan setiap modul punya representasi produk sendiri.
Aggregate & Repository: Setiap modul punya repository yang bertanggung jawab atas satu tabel utama (dan tabel terkait yang hanya diakses lewat aggregate itu). Modul Order tidak boleh langsung query tabel users — ia harus memanggil UserService.findById().
Module Isolation Tanpa Overhead Microservice
Isolasi modul di monolith ditegakkan melalui konvensi kode dan lint rules, bukan jaringan. Berikut contoh aturan yang bisa Anda terapkan:
// modules/order/order.service.ts
import { UserService } from '../user/user.service'; // OK: modul memanggil modul
import { db } from '../../shared/database'; // OK: shared infrastructure
// DILARANG — jangan impor langsung dari repository modul lain:
// import { UserRepository } from '../user/user.repository'; // SALAH
// import { db } from '../../modules/user/...'; // SALAH
export class OrderService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly userService: UserService, // Injeksi, bukan impor langsung
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
// Validasi user lewat UserService, bukan query langsung ke tabel users
const user = await this.userService.findById(dto.userId);
if (!user) throw new NotFoundException('User tidak ditemukan');
const order = await this.orderRepository.create({
userId: dto.userId,
items: dto.items,
totalAmount: this.calculateTotal(dto.items),
});
return order;
}
}
Dengan pola ini, jika suatu hari Anda ingin memisahkan UserService menjadi microservice, yang berubah hanya implementasi UserService — dari function call menjadi HTTP client — bukan semua kode yang memakainya.
Contoh Batas Modul: modules/user dan modules/order
// shared/types/common.types.ts
export interface AuditFields {
created_at: Date;
updated_at: Date;
deleted_at: Date | null;
created_by: string | null;
updated_by: string | null;
deleted_by: string | null;
}
// modules/user/user.types.ts
export interface User extends AuditFields {
id: string;
name: string;
email: string;
role: 'admin' | 'staff' | 'customer';
is_active: boolean;
}
export interface CreateUserDto {
name: string;
email: string;
password: string;
role?: 'staff' | 'customer';
}
// modules/user/user.service.ts — PUBLIC INTERFACE modul User
export class UserService {
async findById(id: string): Promise<User | null> {
return this.userRepository.findById(id);
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findByEmail(email);
}
// Metode ini boleh dipanggil oleh modul lain
async verifyExists(id: string): Promise<void> {
const user = await this.findById(id);
if (!user) throw new NotFoundException(`User ${id} tidak ditemukan`);
}
}
// modules/order/order.types.ts — Order punya representasi User-nya sendiri
export interface OrderUserSnapshot {
userId: string;
userName: string; // Snapshot nama, bukan foreign key ke tabel users
userEmail: string;
}
Kapan Migrasi ke Microservices
Ada tanda-tanda spesifik yang mengindikasikan Anda butuh microservices — bukan perasaan atau tekanan tren teknologi:
| Sinyal | Indikator Konkret | Solusi Sebelum Microservices |
|---|---|---|
| Deploy terlalu sering terganggu | >30 developer push ke branch yang sama setiap hari | Trunk-based development + feature flag |
| Bottleneck performa satu modul | Modul laporan mengonsumsi 80% CPU saat generate PDF | Caching, async queue, read replica |
| Kebutuhan bahasa berbeda | ML inference perlu Python, tapi app utama Node.js | Sidecar process atau gRPC service kecil |
| Regulasi isolasi data | Data kesehatan harus di server terpisah per hukum | Microservice untuk domain spesifik itu saja |
| Tim terpisah secara geografis | Tim A di Jakarta, Tim B di Surabaya — koordinasi sulit | Microservice per tim, Conway's Law dipatuhi |
Kunci migrasi yang sukses adalah: pastikan batas modul Anda sudah jelas dan teruji di monolith. Modul yang sudah punya interface bersih sangat mudah diangkat menjadi microservice — Anda hanya mengganti function call dengan HTTP/gRPC call. Modul yang kodenya saling mengimpor secara acak akan menjadi mimpi buruk ketika dipisahkan.
Inti. Modular Monolith bukan langkah mundur. Amazon, Shopify, dan Stack Overflow beroperasi dengan monolith bertahun-tahun sebelum memisahkan layanan. Yang membuat mereka sukses bukan arsitektur awalnya — melainkan disiplin menjaga batas modul tetap bersih.
Database Architecture (WAJIB)
Database adalah satu-satunya bagian dari sistem Anda yang hampir tidak bisa di-refaktor tanpa downtime dan migrasi data yang berisiko. Pilihan arsitektur database yang Anda buat hari ini — termasuk keputusan tentang foreign key, soft delete, audit trail — akan Anda warisi selama aplikasi itu hidup. Bab ini membahas tiga aturan yang harus menjadi bagian dari setiap proyek yang Anda bangun.
Aturan 1: NO FOREIGN KEY di Level Database
Ini adalah keputusan yang sering memancing perdebatan, karena foreign key adalah fitur inti SQL yang diajarkan di hampir semua kursus database. Namun dalam konteks aplikasi web skala produksi, foreign key di level database membawa masalah yang lebih banyak dari manfaatnya.
Mengapa tidak ada foreign key:
- Performa operasi batch. Saat Anda mengimpor 50.000 baris data produk, database harus memvalidasi setiap foreign key secara sinkron. Dengan mematikan FK validation (
SET FOREIGN_KEY_CHECKS=0), operasi 10x lebih cepat — tetapi ini membuktikan bahwa FK menjadi bottleneck. - Migrasi dan refaktor lebih fleksibel. Mengganti struktur tabel, menggabungkan dua tabel, atau memindahkan kolom jauh lebih mudah tanpa jaringan FK yang harus diputus dulu satu per satu.
- Soft delete incompatibility. Ketika row di-soft-delete (hanya mengisi
deleted_at), row itu masih ada secara fisik. FK constraint akan tetap valid secara teknis, tetapi data terasa “orphaned” secara logika bisnis. - Microservices dan multi-database. Jika suatu hari modul User berpindah ke database terpisah, semua FK yang menunjuk ke tabel
usersharus dihapus. Lebih baik mengelola relasi di application layer sejak awal.
Alternatif: Application-Level Integrity
-- TANPA foreign key constraint
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- Referensi ke users.id, tapi tanpa FK
product_id UUID NOT NULL, -- Referensi ke products.id, tapi tanpa FK
amount NUMERIC(15,2) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
created_by UUID NULL,
updated_by UUID NULL,
deleted_by UUID NULL
);
-- Index manual sebagai pengganti FK index otomatis
CREATE INDEX idx_orders_user_id ON orders (user_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_orders_product_id ON orders (product_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_orders_status ON orders (status) WHERE deleted_at IS NULL;
Di application layer, validasi dilakukan secara eksplisit:
// modules/order/order.service.ts
async createOrder(dto: CreateOrderDto, actorId: string): Promise<Order> {
// Validasi user existence di application layer
const user = await this.userService.findById(dto.userId);
if (!user) {
throw new BadRequestException(`User dengan ID ${dto.userId} tidak ditemukan`);
}
// Validasi product existence
const product = await this.productService.findById(dto.productId);
if (!product) {
throw new BadRequestException(`Produk dengan ID ${dto.productId} tidak ditemukan`);
}
// Validasi stok (logika bisnis, tidak bisa di FK)
if (product.stock < dto.quantity) {
throw new BadRequestException('Stok tidak mencukupi');
}
return this.orderRepository.create({
...dto,
createdBy: actorId,
});
}
Rumus. NO FOREIGN KEY di level database. Integritas dijaga di application layer melalui validasi eksplisit sebelum setiap operasi tulis. Index manual menggantikan FK index otomatis. Dokumentasikan relasi antar-tabel di komentar SQL atau ER diagram, bukan di constraint database.
Aturan 2: SOFT DELETE Wajib
Soft delete berarti tidak pernah menghapus data secara permanen dari database kecuali ada keputusan eksplisit. Sebagai gantinya, Anda mengisi kolom deleted_at dengan timestamp penghapusan. Data yang “dihapus” masih ada di database tetapi disaring dari semua query normal.
Mengapa soft delete wajib: Pengguna salah hapus data dan minta restore (terjadi hampir setiap minggu di aplikasi produksi); regulasi seperti UU PDP atau GDPR mensyaratkan kemampuan audit atas penghapusan; debugging produksi jadi jauh lebih mudah ketika data historis masih ada; dan laporan keuangan perlu data transaksi yang tidak boleh dihapus meskipun statusnya sudah tidak aktif.
Struktur kolom wajib untuk setiap tabel:
-- Template tabel dengan soft delete + audit trail lengkap
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
sku VARCHAR(100) UNIQUE,
price NUMERIC(15,2) NOT NULL DEFAULT 0,
stock INTEGER NOT NULL DEFAULT 0,
category_id UUID NULL, -- NO FK, referensi manual
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Soft delete
deleted_at TIMESTAMPTZ NULL, -- NULL = aktif, TIDAK NULL = dihapus
-- Audit trail
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NULL, -- ID user yang membuat
updated_by UUID NULL, -- ID user yang terakhir update
deleted_by UUID NULL -- ID user yang menghapus
);
-- Index partial: hanya data aktif yang masuk index utama
CREATE INDEX idx_products_active ON products (name, sku) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_category ON products (category_id) WHERE deleted_at IS NULL;
-- Index untuk query data terhapus (admin panel restore)
CREATE INDEX idx_products_deleted ON products (deleted_at) WHERE deleted_at IS NOT NULL;
Base Repository dengan Soft Delete Global Scope:
// shared/database/base-repository.ts
import { Knex } from 'knex';
export abstract class BaseRepository<T> {
constructor(
protected readonly db: Knex,
protected readonly tableName: string,
) {}
// Semua query default hanya ambil data aktif (deleted_at IS NULL)
protected activeQuery(): Knex.QueryBuilder {
return this.db(this.tableName).whereNull('deleted_at');
}
// Query yang menyertakan data terhapus (untuk admin/restore)
protected withTrashedQuery(): Knex.QueryBuilder {
return this.db(this.tableName);
}
async findById(id: string): Promise<T | null> {
const row = await this.activeQuery().where({ id }).first();
return row ?? null;
}
async findAll(options?: { page?: number; limit?: number }): Promise<T[]> {
const page = options?.page ?? 1;
const limit = options?.limit ?? 20;
return this.activeQuery()
.offset((page - 1) * limit)
.limit(limit)
.orderBy('created_at', 'desc');
}
async softDelete(id: string, deletedBy: string): Promise<void> {
await this.db(this.tableName).where({ id }).update({
deleted_at: new Date(),
deleted_by: deletedBy,
updated_at: new Date(),
});
}
async restore(id: string, restoredBy: string): Promise<void> {
await this.db(this.tableName).where({ id }).update({
deleted_at: null,
deleted_by: null,
updated_by: restoredBy,
updated_at: new Date(),
});
}
async forceDelete(id: string): Promise<void> {
// Hard delete — hanya untuk kebutuhan khusus (GDPR right to erasure)
await this.db(this.tableName).where({ id }).delete();
}
}
Aturan 3: RESTORE Data
Setiap operasi soft delete harus punya endpoint restore yang setara. Pengguna dengan hak akses yang sesuai harus bisa mengembalikan data yang dihapus.
// modules/product/product.controller.ts
// DELETE /api/v1/products/:id → soft delete
router.delete('/:id', authenticate, authorize('admin', 'staff'), async (req, res) => {
await productService.softDelete(req.params.id, req.user.id);
res.json({ success: true, message: 'Produk berhasil dihapus' });
});
// POST /api/v1/products/:id/restore → kembalikan data terhapus
router.post('/:id/restore', authenticate, authorize('admin'), async (req, res) => {
const product = await productService.restore(req.params.id, req.user.id);
res.json({ success: true, data: product, message: 'Produk berhasil dipulihkan' });
});
// GET /api/v1/products/trashed → daftar produk yang sudah dihapus
router.get('/trashed', authenticate, authorize('admin'), async (req, res) => {
const { page = 1, limit = 20 } = req.query;
const data = await productService.findTrashed({ page: +page, limit: +limit });
res.json({ success: true, data });
});
// modules/product/product.service.ts
async restore(id: string, restoredBy: string): Promise<Product> {
const product = await this.productRepository.findTrashedById(id);
if (!product) throw new NotFoundException('Produk tidak ditemukan atau belum dihapus');
await this.productRepository.restore(id, restoredBy);
return this.productRepository.findById(id) as Promise<Product>;
}
Soft Delete Cascade Logic
Ketika sebuah entitas induk dihapus, entitas anaknya perlu ikut dihapus secara logis. Karena kita tidak punya FK cascade, kita implementasikan cascade di application layer:
// modules/category/category.service.ts
async softDelete(categoryId: string, deletedBy: string): Promise<void> {
// Cek apakah kategori ada
const category = await this.categoryRepository.findById(categoryId);
if (!category) throw new NotFoundException('Kategori tidak ditemukan');
// Soft delete semua produk dalam kategori ini
await this.productRepository.softDeleteByCategoryId(categoryId, deletedBy);
// Baru soft delete kategorinya
await this.categoryRepository.softDelete(categoryId, deletedBy);
// Log ke audit trail
await this.auditService.log({
action: 'CASCADE_DELETE',
entityType: 'category',
entityId: categoryId,
actorId: deletedBy,
meta: { cascadedTo: 'products' },
});
}
Audit Trail: created_by / updated_by / deleted_by
Audit trail adalah kemampuan menjawab pertanyaan: “Siapa yang mengubah data ini, kapan, dan apa yang berubah?” Kolom created_by, updated_by, dan deleted_by menjawab siapa dan kapan. History table menjawab apa yang berubah.
-- Tabel audit log untuk setiap aksi penting
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type VARCHAR(100) NOT NULL, -- 'product', 'order', 'user'
entity_id UUID NOT NULL,
action VARCHAR(50) NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE', 'RESTORE'
actor_id UUID NULL, -- ID user yang melakukan aksi
actor_name VARCHAR(255) NULL, -- Snapshot nama (karena user bisa berubah)
old_values JSONB NULL, -- State sebelum perubahan
new_values JSONB NULL, -- State setelah perubahan
ip_address INET NULL,
user_agent TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_entity ON audit_logs (entity_type, entity_id);
CREATE INDEX idx_audit_actor ON audit_logs (actor_id);
CREATE INDEX idx_audit_created ON audit_logs (created_at DESC);
History Table Pattern
Untuk data kritis seperti harga produk, saldo, atau kontrak, Anda perlu history table yang menyimpan setiap versi data:
-- History table untuk produk (menyimpan semua versi)
CREATE TABLE products_history (
history_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL,
operation VARCHAR(10) NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
changed_by UUID NULL,
-- Kolom data produk (salin semua kolom dari tabel products)
name VARCHAR(255),
sku VARCHAR(100),
price NUMERIC(15,2),
stock INTEGER,
is_active BOOLEAN,
deleted_at TIMESTAMPTZ NULL
);
-- Trigger PostgreSQL untuk otomatis mencatat perubahan
CREATE OR REPLACE FUNCTION log_product_changes()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO products_history (product_id, operation, changed_by, name, sku, price, stock, is_active)
VALUES (NEW.id, 'INSERT', NEW.created_by, NEW.name, NEW.sku, NEW.price, NEW.stock, NEW.is_active);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO products_history (product_id, operation, changed_by, name, sku, price, stock, is_active, deleted_at)
VALUES (NEW.id, 'UPDATE', NEW.updated_by, NEW.name, NEW.sku, NEW.price, NEW.stock, NEW.is_active, NEW.deleted_at);
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_products_history
AFTER INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION log_product_changes();
Indexing Strategy
Index yang buruk adalah penyebab nomor satu query lambat di aplikasi produksi. Gunakan prinsip berikut:
| Skenario | Jenis Index | Contoh |
|---|---|---|
| Filter kolom tunggal yang sering dipakai | B-tree standard | idx_orders_user_id |
| Query hanya pada data aktif (soft delete) | Partial index | WHERE deleted_at IS NULL |
| Search teks bebas | GIN + tsvector | idx_products_fts |
| Filter multi-kolom berurutan | Composite index | (status, created_at DESC) |
| Data JSONB (PostgreSQL) | GIN index | idx_meta_gin ON orders USING GIN(meta) |
| Kolom UUID yang banyak di-JOIN | B-tree standard | Sudah otomatis di PK, buat di FK manual |
Tips. Jalankan
EXPLAIN ANALYZEpada setiap query yang memiliki waktu eksekusi >100ms. Index yang benar biasanya memotong waktu query 10–100x. Jangan menambah index secara spekulatif — index memperlambat operasi INSERT/UPDATE karena perlu diperbarui.
Partisi Data
Ketika tabel audit_logs atau orders mulai melampaui 50–100 juta baris, partisi per bulan menjadi penting untuk performa query dan maintenance:
-- Partisi tabel audit_logs per bulan (PostgreSQL range partitioning)
CREATE TABLE audit_logs (
id UUID NOT NULL DEFAULT gen_random_uuid(),
entity_type VARCHAR(100) NOT NULL,
entity_id UUID NOT NULL,
action VARCHAR(50) NOT NULL,
actor_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Buat partisi otomatis tiap bulan
CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE audit_logs_2025_02 PARTITION OF audit_logs
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- Query hanya menyentuh partisi yang relevan (partition pruning)
SELECT * FROM audit_logs
WHERE created_at >= '2025-06-01' AND created_at < '2025-07-01'
AND entity_type = 'order';
Memilih Database
Database bukan sekadar “tempat menyimpan data.” Pilihan database menentukan model konsistensi yang bisa Anda jamin kepada pengguna, kompleksitas operasional yang harus Anda kelola, kemampuan query yang tersedia, dan ekosistem tooling yang mendukung development sehari-hari. Memilih MongoDB karena “lebih modern” atau MySQL karena “yang diajarkan di kampus” adalah keputusan yang sering disesali di bulan keenam.
Perbandingan Database Utama
| Fitur | PostgreSQL | MySQL 8 | MariaDB | SQLite | MongoDB |
|---|---|---|---|---|---|
| Tipe data kaya | Ya (JSONB, ARRAY, UUID, INET) | Terbatas (JSON tanpa index GIN) | Mirip MySQL | Minimal | Ya (BSON) |
| Full-text search | Built-in (tsvector) | Built-in (terbatas) | Built-in | Dasar | Atlas Search / Lucene |
| ACID compliance | Penuh | Penuh (InnoDB) | Penuh | Penuh | Multi-doc transaksi (v4+) |
| Window functions | Ya | Ya (v8+) | Ya | Ya (v3.25+) | Tidak |
| Replication | Streaming (logical/physical) | Ya | Galera cluster | Tidak | Replica set |
| Managed cloud | RDS, Supabase, Neon, Railway | RDS, PlanetScale | RDS | Turso, embedded | Atlas |
| Lisensi | PostgreSQL (open) | GPL/Commercial | GPL (open) | Public domain | SSPL (partial open) |
| Cocok untuk MVP | Sangat direkomendasikan | Baik | Baik | Prototipe/offline app | Konten dinamis/IoT |
Rumus. Untuk hampir semua aplikasi web MVP, pilih PostgreSQL. Ia memiliki fitur paling lengkap di antara database relasional open-source, komunitas terbesar, dan layanan managed yang tersedia di hampir semua cloud provider. Satu-satunya alasan untuk tidak memilihnya adalah tim yang sudah sangat familiar dengan alternatif tertentu.
Kapan SQL vs NoSQL
Keputusan SQL vs NoSQL bukan tentang “modern vs lama” — keduanya aktif dikembangkan. Ini tentang model data dan pola akses:
| Kondisi | SQL (Relasional) | NoSQL |
|---|---|---|
| Data terstruktur, skema konsisten | Ya | Bisa, tapi berlebihan |
| Relasi kompleks antar-entitas | Ya (JOIN efisien) | Sulit (embedding atau denormalisasi) |
| Transaksi ACID multi-tabel | Ya, native | Terbatas/kompleks |
| Skema berubah-ubah antar-record | Tidak ideal | Ya (document DB) |
| Tulis sangat tinggi (>100k/s) | Butuh tuning | Ya (Cassandra, DynamoDB) |
| Graph relationship (social network) | Bisa, tapi lambat | Ya (Neo4j, ArangoDB) |
| Cache + session | Tidak ideal | Ya (Redis) |
| Search full-text kompleks | Terbatas | Ya (Elasticsearch) |
Rekomendasi per Jenis Aplikasi
| Jenis Aplikasi | Database Primer | Database Sekunder |
|---|---|---|
| SaaS B2B (ERP, CRM, HRIS) | PostgreSQL | Redis (cache + queue) |
| E-commerce | PostgreSQL | Redis + Elasticsearch |
| Aplikasi media/konten (blog, CMS) | PostgreSQL | Redis |
| Aplikasi IoT (telemetri) | TimescaleDB (PostgreSQL extension) | Redis |
| Chat/messaging real-time | PostgreSQL + MongoDB | Redis Pub/Sub |
| Mobile app offline-first | SQLite (lokal) + PostgreSQL (server) | — |
| Analytics/reporting besar | PostgreSQL + ClickHouse | — |
| Prototipe/MVP personal | SQLite | — |
ORM vs Raw Query
ORM (Prisma, TypeORM, Sequelize, Eloquent, SQLAlchemy) mempercepat development dengan menyediakan abstraksi di atas SQL. Raw query memberikan kontrol penuh dan performa maksimal. Pilih berdasarkan kebutuhan:
Kapan pakai ORM: CRUD standar, data model yang sering berubah selama development, tim dengan latar belakang beragam yang tidak semua nyaman dengan SQL, atau ketika type safety end-to-end (Prisma + TypeScript) menjadi prioritas.
Kapan pakai Raw Query: Query dengan JOIN kompleks, query dengan agregasi berat, operasi bulk insert/update >1000 baris, atau ketika ORM generate SQL yang tidak optimal.
// Hybrid approach: ORM untuk CRUD, raw query untuk analitik
// Menggunakan Prisma + raw query PostgreSQL
// CRUD standar: Prisma
const order = await prisma.order.create({
data: {
userId: dto.userId,
totalAmount: dto.totalAmount,
status: 'pending',
createdBy: actorId,
},
});
// Query analitik: raw SQL
const salesReport = await prisma.$queryRaw`
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total_amount) AS total_revenue,
COUNT(*) AS total_orders,
COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
WHERE deleted_at IS NULL
AND status = 'completed'
AND created_at >= ${startDate}
AND created_at < ${endDate}
GROUP BY 1
ORDER BY 1 DESC
`;
Query Optimization Basics
Lima teknik yang menyelesaikan 90% masalah performa query:
- EXPLAIN ANALYZE: Jalankan selalu sebelum anggap query sudah optimal. Perhatikan Seq Scan (tanda index tidak dipakai) dan actual rows yang jauh berbeda dari estimasi.
- Partial index: Tambahkan
WHERE deleted_at IS NULLpada semua index tabel ber-soft-delete. Index menjadi lebih kecil dan query lebih cepat. - N+1 problem: Jangan query di dalam loop. Gunakan JOIN atau query batch: ambil semua order sekaligus, lalu ambil semua user berdasarkan user_id yang unik.
- Pagination dengan cursor: Hindari
OFFSETbesar. Gunakan cursor-based pagination denganWHERE id > :lastId ORDER BY id LIMIT 20. - Connection pooling: Gunakan PgBouncer atau pgpool — setiap koneksi PostgreSQL mengonsumsi sekitar 5–10 MB memori. Aplikasi dengan 100 concurrent user tanpa pooling bisa membuat database kehabisan memori.
Redis Caching & Queue
Redis adalah struktur data in-memory yang beroperasi di RAM, sehingga latensi aksesnya diukur dalam mikrodetik — 100–1000x lebih cepat dari PostgreSQL yang harus membaca disk. Dalam ekosistem aplikasi web modern, Redis memainkan tiga peran berbeda yang sering dijalankan bersamaan: cache untuk mengurangi beban database, session store untuk menyimpan state pengguna, dan queue untuk memproses pekerjaan secara asinkron.
Redis untuk Caching
Ada tiga level caching yang bisa Anda terapkan dengan Redis, dari yang paling sederhana hingga yang paling agresif:
1. Query Cache: Cache hasil query database yang sering dipanggil namun jarang berubah. Misalnya daftar provinsi, kategori produk, atau konfigurasi sistem.
// infrastructure/cache/redis.cache.ts
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
export async function withCache<T>(
key: string,
ttlSeconds: number,
fetchFn: () => Promise<T>,
): Promise<T> {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
const data = await fetchFn();
await redis.setEx(key, ttlSeconds, JSON.stringify(data));
return data;
}
// Penggunaan di service
async getCategories(): Promise<Category[]> {
return withCache(
'categories:all',
3600, // Cache 1 jam
() => this.categoryRepository.findAll(),
);
}
// Invalidasi cache ketika data berubah
async createCategory(dto: CreateCategoryDto): Promise<Category> {
const category = await this.categoryRepository.create(dto);
await redis.del('categories:all'); // Hapus cache yang sudah tidak valid
return category;
}
2. Session Cache: Simpan session user di Redis alih-alih database. Setiap request autentikasi cukup mengambil data dari Redis tanpa query ke PostgreSQL.
// config/session.ts — menggunakan connect-redis
import session from 'express-session';
import RedisStore from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 hari
},
}));
3. Full-Page Cache: Cache seluruh response HTTP untuk endpoint yang datanya jarang berubah namun sangat sering diakses. Cocok untuk halaman publik seperti beranda atau daftar produk tanpa autentikasi.
// shared/middleware/page-cache.middleware.ts
export function pageCacheMiddleware(ttlSeconds: number) {
return async (req: Request, res: Response, next: NextFunction) => {
if (req.method !== 'GET') return next();
const cacheKey = `page:${req.originalUrl}`;
const cached = await redis.get(cacheKey);
if (cached) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Content-Type', 'application/json');
return res.send(cached);
}
// Override res.json untuk intercept response
const originalJson = res.json.bind(res);
res.json = (data: unknown) => {
redis.setEx(cacheKey, ttlSeconds, JSON.stringify(data));
res.setHeader('X-Cache', 'MISS');
return originalJson(data);
};
next();
};
}
// Penggunaan di router
router.get('/products', pageCacheMiddleware(300), productController.list);
Redis Queue dengan BullMQ
BullMQ adalah library queue berbasis Redis yang memungkinkan Anda menjalankan pekerjaan berat (kirim email, generate PDF, resize gambar, kirim notifikasi push) secara asinkron di background worker, tanpa memblokir HTTP request.
// infrastructure/queue/queues.ts
import { Queue, Worker, QueueEvents } from 'bullmq';
import { redis } from '../redis';
// Definisi queue
export const emailQueue = new Queue('email', { connection: redis });
export const pdfQueue = new Queue('pdf-export', { connection: redis });
export const notifQueue = new Queue('notification', { connection: redis });
// Tambahkan job ke queue (di controller/service)
await emailQueue.add('send-welcome', {
to: user.email,
name: user.name,
templateId: 'welcome-onboarding',
}, {
attempts: 3, // Coba ulang 3x jika gagal
backoff: {
type: 'exponential',
delay: 2000, // Jeda awal 2 detik, lalu 4s, 8s
},
removeOnComplete: 100, // Simpan 100 job terakhir yang sukses
removeOnFail: 50, // Simpan 50 job terakhir yang gagal
});
// Worker (jalankan di proses terpisah atau thread)
const emailWorker = new Worker('email', async (job) => {
const { to, name, templateId } = job.data;
await job.updateProgress(10);
const template = await loadEmailTemplate(templateId);
await job.updateProgress(50);
await sendEmail({ to, subject: template.subject, html: template.render({ name }) });
await job.updateProgress(100);
console.log(`Email terkirim ke ${to}`);
}, {
connection: redis,
concurrency: 5, // Proses 5 email secara paralel
});
// Event listener untuk monitoring
emailWorker.on('completed', (job) => {
console.log(`Job ${job.id} selesai`);
});
emailWorker.on('failed', (job, err) => {
console.error(`Job ${job?.id} gagal:`, err.message);
// Kirim alert ke monitoring system
});
Rate Limiting dengan Redis
Rate limiting melindungi API dari penyalahgunaan. Redis adalah backend ideal karena ia atomic, cepat, dan mendukung TTL otomatis:
// shared/middleware/rate-limit.middleware.ts
export function rateLimitMiddleware(options: {
windowSeconds: number;
maxRequests: number;
keyPrefix?: string;
}) {
return async (req: Request, res: Response, next: NextFunction) => {
const identifier = req.user?.id ?? req.ip;
const key = `ratelimit:${options.keyPrefix ?? 'global'}:${identifier}`;
const current = await redis.incr(key);
if (current === 1) {
// Pertama kali dalam window ini — set TTL
await redis.expire(key, options.windowSeconds);
}
const ttl = await redis.ttl(key);
res.setHeader('X-RateLimit-Limit', options.maxRequests);
res.setHeader('X-RateLimit-Remaining', Math.max(0, options.maxRequests - current));
res.setHeader('X-RateLimit-Reset', Date.now() + ttl * 1000);
if (current > options.maxRequests) {
return res.status(429).json({
success: false,
error: 'RATE_LIMIT_EXCEEDED',
message: `Terlalu banyak permintaan. Coba lagi dalam ${ttl} detik.`,
});
}
next();
};
}
// Penggunaan: berbeda limit untuk endpoint berbeda
router.post('/auth/login',
rateLimitMiddleware({ windowSeconds: 900, maxRequests: 10, keyPrefix: 'login' }),
authController.login,
);
router.get('/api/products',
rateLimitMiddleware({ windowSeconds: 60, maxRequests: 100 }),
productController.list,
);
Estimasi Memori Redis per Use Case
| Use Case | Ukuran per Item | 10.000 item | TTL Wajar |
|---|---|---|---|
| Session user (JWT claims + meta) | ~2 KB | ~20 MB | 7 hari |
| Cache query list produk | ~50 KB (JSON 100 item) | 500 MB | 5–60 menit |
| Cache detail produk | ~3 KB | ~30 MB | 30 menit |
| Rate limit counter | ~100 B | ~1 MB | Sesuai window |
| BullMQ job pending | ~500 B | ~5 MB | Sampai diproses |
| OTP / verification code | ~200 B | ~2 MB | 5–15 menit |
Tips. Mulai dengan Redis instance 512 MB (sekitar Rp 100–150 ribu/bulan di layanan managed seperti Upstash atau Redis Cloud). Pantau penggunaan memori dengan
INFO memorydan aktifkanmaxmemory-policy allkeys-lruagar Redis otomatis membuang data yang paling jarang diakses ketika memori penuh.
API Design & RESTful Standards
API yang dirancang baik adalah kontrak. Kontrak yang jelas membuat frontend developer bisa bekerja paralel dengan backend developer, memudahkan integrasi pihak ketiga, dan memungkinkan pengguna API untuk memprediksi perilaku endpoint yang belum pernah mereka panggil. API yang dirancang buruk adalah utang teknis yang mengharuskan dokumentasi panjang dan masih membingungkan.
REST Best Practices
REST bukan protokol formal — ia adalah kumpulan konvensi. Berikut konvensi yang paling penting untuk konsistensi:
Gunakan kata benda jamak untuk resource: /api/v1/products, bukan /api/v1/product atau /api/v1/getProduct. Verb datang dari HTTP method, bukan URL.
HTTP method yang benar:
GET /products— daftar semua produk (paginated)GET /products/:id— detail satu produkPOST /products— buat produk baruPUT /products/:id— update penuh (semua field)PATCH /products/:id— update sebagian (hanya field yang dikirim)DELETE /products/:id— soft deletePOST /products/:id/restore— restore (aksi khusus pakai POST)
API Versioning
Versioning memastikan perubahan breaking di API tidak merusak client yang sudah berjalan. Ada tiga strategi umum:
| Strategi | Contoh | Pro | Kontra |
|---|---|---|---|
| URL prefix | /api/v1/products | Mudah di-routing, eksplisit | URL berubah, tidak REST-pure |
Header Accept | Accept: application/vnd.api.v1+json | URL bersih | Sulit di-debug, perlu dokumentasi |
| Query param | /products?version=1 | Mudah di-test | Tidak konvensional, bisa disalahartikan |
Rumus. Gunakan URL prefix versioning (
/api/v1/). Ia paling eksplisit, paling mudah didebug, dan paling mudah diatur di reverse proxy (Nginx, Caddy) atau API gateway.
Format Response Standar
Semua endpoint harus mengembalikan format yang konsisten — berhasil maupun gagal. Inkonsistensi format response adalah penyebab utama bug di frontend.
// shared/types/api-response.types.ts
// Response sukses dengan satu item
{
"success": true,
"data": {
"id": "uuid-here",
"name": "Produk A",
"price": 150000,
"created_at": "2025-06-15T10:00:00.000Z"
},
"meta": null
}
// Response sukses dengan list + pagination
{
"success": true,
"data": [ ... ],
"meta": {
"page": 1,
"limit": 20,
"total": 234,
"total_pages": 12,
"has_next": true,
"has_prev": false
}
}
// Response error (validasi)
{
"success": false,
"error": "VALIDATION_ERROR",
"message": "Data yang dikirim tidak valid",
"details": [
{ "field": "email", "message": "Format email tidak valid" },
{ "field": "price", "message": "Harga harus lebih dari 0" }
]
}
// Response error (server)
{
"success": false,
"error": "INTERNAL_SERVER_ERROR",
"message": "Terjadi kesalahan pada server. Silakan coba lagi.",
"request_id": "req_xyz123"
}
// shared/utils/api-response.ts — helper untuk konsistensi
export const ApiResponse = {
success<T>(data: T, meta?: PaginationMeta) {
return { success: true, data, meta: meta ?? null };
},
error(code: string, message: string, details?: ValidationDetail[]) {
return { success: false, error: code, message, details: details ?? undefined };
},
paginated<T>(data: T[], total: number, page: number, limit: number) {
const total_pages = Math.ceil(total / limit);
return {
success: true,
data,
meta: {
page, limit, total, total_pages,
has_next: page < total_pages,
has_prev: page > 1,
},
};
},
};
// Penggunaan di controller
res.json(ApiResponse.success(product));
res.json(ApiResponse.paginated(products, total, page, limit));
res.status(422).json(ApiResponse.error('VALIDATION_ERROR', 'Data tidak valid', errors));
Pagination, Filtering, dan Sorting
Konvensi query parameter yang konsisten untuk semua list endpoint:
// GET /api/v1/products?page=2&limit=20&sort=price&order=asc&category_id=uuid&search=laptop
// shared/utils/pagination.ts
export function parsePaginationQuery(query: Record<string, string>) {
const page = Math.max(1, parseInt(query.page ?? '1'));
const limit = Math.min(100, Math.max(1, parseInt(query.limit ?? '20')));
const sort = query.sort ?? 'created_at';
const order = (query.order ?? 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
const allowedSortFields = ['name', 'price', 'created_at', 'updated_at'];
const safeSortField = allowedSortFields.includes(sort) ? sort : 'created_at';
return {
page,
limit,
offset: (page - 1) * limit,
sort: safeSortField,
order,
};
}
OpenAPI / Swagger
Dokumentasi API bukan opsional untuk tim lebih dari satu orang. OpenAPI Spec (format JSON/YAML) adalah standar industri yang menghasilkan dokumentasi interaktif dan bisa digunakan untuk generate client SDK.
# openapi.yaml (sebagian)
openapi: "3.0.3"
info:
title: "Rumus Aplikasi API"
version: "1.0.0"
paths:
/api/v1/products:
get:
summary: "Daftar produk"
tags: [Products]
security:
- bearerAuth: []
parameters:
- in: query
name: page
schema: { type: integer, default: 1 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 100 }
- in: query
name: search
schema: { type: string }
responses:
"200":
description: "Berhasil"
content:
application/json:
schema:
$ref: "#/components/schemas/ProductListResponse"
"401":
$ref: "#/components/responses/Unauthorized"
Rate Limiting & Throttling
Setiap API publik harus dilindungi dengan rate limiting. Beda endpoint, beda batas:
| Endpoint | Limit | Window | Alasan |
|---|---|---|---|
| POST /auth/login | 10 req | 15 menit | Cegah brute force password |
| POST /auth/otp/request | 3 req | 10 menit | Cegah spam SMS/email |
| GET /products (public) | 200 req | 1 menit | Cegah scraping berlebihan |
| GET /products (authenticated) | 1000 req | 1 menit | Limit wajar untuk aplikasi aktif |
| POST /orders | 30 req | 1 menit | Cegah order spam |
| POST /files/upload | 20 req | 1 jam | Cegah penyalahgunaan storage |
Otentikasi & Otorisasi
Otentikasi menjawab pertanyaan: “Siapa kamu?” Otorisasi menjawab: “Apa yang boleh kamu lakukan?” Keduanya berbeda lapisan dan sering dicampuradukkan, menghasilkan celah keamanan yang kelihatannya tidak mungkin namun sangat nyata. Bab ini membahas implementasi keduanya yang aman, skalabel, dan mudah dipelihara.
JWT vs Session
Ada dua pendekatan utama untuk menyimpan “bukti identitas” setelah pengguna login:
| Aspek | JWT (Stateless) | Session + Redis (Stateful) |
|---|---|---|
| Penyimpanan state | Di client (cookie atau localStorage) | Di server (Redis) |
| Revoke sebelum expire | Sulit (perlu token blacklist di Redis) | Mudah (hapus session dari Redis) |
| Skalabilitas horizontal | Mudah (tidak perlu sticky session) | Mudah jika Redis shared |
| Payload data | Bisa menyimpan claims (role, userId) | Hanya ID session di client |
| Keamanan logout | Token di client masih valid sampai expire | Logout instant (hapus session) |
| Ukuran payload | ~200–500 B setiap request (header) | ~20 B (session ID) setiap request |
Rumus. Untuk aplikasi web dengan kebutuhan logout yang bisa diandalkan (perbankan, kesehatan, enterprise), gunakan Session + Redis. Untuk API yang diakses mobile app atau SPA tanpa cookie, JWT lebih praktis. Jangan pakai
localStorageuntuk JWT — gunakanhttpOnly cookieagar tidak bisa diakses JavaScript (mencegah XSS).
Implementasi JWT yang aman:
// modules/auth/auth.service.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
export class AuthService {
async login(email: string, password: string): Promise<TokenPair> {
const user = await this.userRepository.findByEmail(email);
if (!user) throw new UnauthorizedException('Email atau password salah');
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) throw new UnauthorizedException('Email atau password salah');
if (!user.is_active) throw new ForbiddenException('Akun Anda tidak aktif');
return this.generateTokenPair(user);
}
private generateTokenPair(user: User): TokenPair {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
};
const accessToken = jwt.sign(payload, JWT_SECRET, {
expiresIn: '15m', // Access token pendek: 15 menit
issuer: 'rumus-app',
});
const refreshToken = jwt.sign(
{ sub: user.id },
JWT_REFRESH_SECRET,
{ expiresIn: '7d' } // Refresh token panjang: 7 hari
);
// Simpan refresh token di Redis untuk validasi
redis.setEx(`refresh:${user.id}`, 7 * 24 * 3600, refreshToken);
return { accessToken, refreshToken, expiresIn: 900 };
}
async refresh(refreshToken: string): Promise<TokenPair> {
const payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as { sub: string };
// Validasi refresh token di Redis (cegah reuse setelah revoke)
const stored = await redis.get(`refresh:${payload.sub}`);
if (stored !== refreshToken) throw new UnauthorizedException('Token tidak valid');
const user = await this.userRepository.findById(payload.sub);
if (!user || !user.is_active) throw new UnauthorizedException('Akun tidak aktif');
return this.generateTokenPair(user);
}
async logout(userId: string): Promise<void> {
await redis.del(`refresh:${userId}`); // Revoke refresh token
}
}
RBAC (Role-Based Access Control)
RBAC mendefinisikan hak akses berdasarkan peran (role) pengguna. Ini jauh lebih mudah dikelola dibanding menetapkan izin per-pengguna.
// shared/auth/rbac.ts
type Role = 'superadmin' | 'admin' | 'staff' | 'customer';
type Permission =
| 'users:read' | 'users:write' | 'users:delete'
| 'products:read' | 'products:write' | 'products:delete'
| 'orders:read' | 'orders:write' | 'orders:delete'
| 'reports:read' | 'settings:write';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
superadmin: [
'users:read', 'users:write', 'users:delete',
'products:read', 'products:write', 'products:delete',
'orders:read', 'orders:write', 'orders:delete',
'reports:read', 'settings:write',
],
admin: [
'users:read', 'users:write',
'products:read', 'products:write', 'products:delete',
'orders:read', 'orders:write',
'reports:read',
],
staff: [
'products:read', 'products:write',
'orders:read', 'orders:write',
],
customer: [
'products:read',
'orders:read',
],
};
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
// Middleware otorisasi
export function authorize(...permissions: Permission[]) {
return (req: Request, res: Response, next: NextFunction) => {
const userRole = req.user?.role as Role;
const allowed = permissions.some(p => hasPermission(userRole, p));
if (!allowed) {
return res.status(403).json(
ApiResponse.error('FORBIDDEN', 'Anda tidak punya izin untuk aksi ini')
);
}
next();
};
}
// Penggunaan di router
router.delete('/products/:id',
authenticate,
authorize('products:delete'),
productController.delete,
);
2FA dan OTP
Two-Factor Authentication (2FA) menambah lapisan keamanan dengan mensyaratkan bukti kedua selain password. OTP (One-Time Password) adalah implementasi paling umum untuk aplikasi web.
// modules/auth/otp.service.ts
import { authenticator } from 'otplib';
export class OtpService {
// OTP via email/SMS (6 digit, berlaku 5 menit)
async requestOtp(userId: string, channel: 'email' | 'sms'): Promise<void> {
const otp = Math.floor(100000 + Math.random() * 900000).toString();
const key = `otp:${userId}:${channel}`;
// Simpan di Redis dengan TTL 5 menit
await redis.setEx(key, 300, otp);
if (channel === 'email') {
await emailQueue.add('send-otp', {
to: await this.userService.getEmail(userId),
otp,
expiresInMinutes: 5,
});
} else {
await smsQueue.add('send-otp-sms', {
phone: await this.userService.getPhone(userId),
otp,
});
}
}
async verifyOtp(userId: string, channel: 'email' | 'sms', otp: string): Promise<boolean> {
const key = `otp:${userId}:${channel}`;
const stored = await redis.get(key);
if (!stored || stored !== otp) return false;
// Hapus OTP setelah digunakan (one-time use)
await redis.del(key);
return true;
}
// TOTP (Google Authenticator) untuk 2FA permanen
generateTotpSecret(): string {
return authenticator.generateSecret();
}
verifyTotp(token: string, secret: string): boolean {
return authenticator.verify({ token, secret });
}
}
Social Login
Integrasi Google OAuth dan GitHub OAuth dengan Passport.js memungkinkan pengguna login tanpa membuat password baru:
// config/passport.ts
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
callbackURL: '/auth/google/callback',
}, async (accessToken, refreshToken, profile, done) => {
try {
let user = await userRepository.findByGoogleId(profile.id);
if (!user) {
// Auto-register jika belum ada akun
user = await userRepository.create({
name: profile.displayName,
email: profile.emails?.[0].value ?? '',
googleId: profile.id,
avatarUrl: profile.photos?.[0].value,
role: 'customer',
isActive: true,
createdBy: null,
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}));
API Key Management
Untuk integrasi machine-to-machine (misalnya partner bisnis mengakses API Anda), API key lebih praktis dari JWT karena tidak ada expiry yang perlu di-refresh.
-- Tabel api_keys
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- Pemilik key (NO FK)
name VARCHAR(100) NOT NULL, -- Label: "Integrasi Tokopedia"
key_hash VARCHAR(255) NOT NULL, -- Hash dari API key (jangan simpan plain text)
key_prefix VARCHAR(8) NOT NULL, -- 8 karakter pertama untuk identifikasi
permissions JSONB NOT NULL DEFAULT '[]',
last_used_at TIMESTAMPTZ NULL,
expires_at TIMESTAMPTZ NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NULL
);
CREATE UNIQUE INDEX idx_apikeys_hash ON api_keys (key_hash);
CREATE INDEX idx_apikeys_user ON api_keys (user_id) WHERE is_active = TRUE;
// modules/auth/api-key.service.ts
import crypto from 'crypto';
export class ApiKeyService {
async generateKey(userId: string, name: string, permissions: string[]): Promise<{
plainKey: string;
record: ApiKey;
}> {
// Generate random 32-byte key, encode base64url
const plainKey = 'rka_' + crypto.randomBytes(32).toString('base64url');
const keyHash = crypto.createHash('sha256').update(plainKey).digest('hex');
const keyPrefix = plainKey.substring(0, 8);
const record = await this.apiKeyRepository.create({
userId,
name,
keyHash,
keyPrefix,
permissions,
createdBy: userId,
});
// plainKey hanya dikembalikan sekali — tidak disimpan di DB
return { plainKey, record };
}
async validateKey(plainKey: string): Promise<ApiKey | null> {
const keyHash = crypto.createHash('sha256').update(plainKey).digest('hex');
const apiKey = await this.apiKeyRepository.findByHash(keyHash);
if (!apiKey || !apiKey.is_active) return null;
if (apiKey.expires_at && apiKey.expires_at < new Date()) return null;
// Update last_used_at secara asinkron (tidak block request)
this.apiKeyRepository.updateLastUsed(apiKey.id).catch(() => {});
return apiKey;
}
}
Inti. Keamanan API bukan fitur yang ditambahkan di akhir. Rancang otentikasi dan otorisasi dari hari pertama: JWT dengan access+refresh token, RBAC dengan permission granular, OTP untuk aksi sensitif, dan API key untuk integrasi. Setiap layer melindungi layer berikutnya.
UI/UX, Wireframing & Mockup
UX Research untuk MVP
Sebagian besar aplikasi yang gagal bukan karena kodenya jelek, melainkan karena membangun sesuatu yang tidak benar-benar dibutuhkan pengguna. UX Research adalah proses terstruktur untuk menghindari perangkap itu — bukan sesi riset akademis berbulan-bulan, melainkan serangkaian aktivitas ringkas yang bisa selesai dalam satu hingga dua minggu dan menghasilkan keputusan desain yang lebih tajam sebelum satu baris pun kode frontend ditulis.
Mengapa UX Research penting untuk MVP
MVP sering diartikan sebagai “buat semurah dan secepat mungkin”. Ini salah kaprah. MVP yang benar adalah produk dengan fitur minimum yang cukup untuk menguji hipotesis bisnis inti. Bila Anda tidak tahu siapa penggunanya dan apa masalah terbesarnya, Anda tidak bisa mendefinisikan “minimum” dengan tepat — Anda hanya menebak.
Bahkan lima sesi wawancara pengguna sudah cukup untuk menemukan pola masalah utama. Nielsen Norman Group menyatakan bahwa lima pengguna mengungkap sekitar 85% masalah kegunaan. Anda tidak butuh lab riset mahal — butuh pertanyaan yang tepat dan keberanian untuk mendengarkan jawabannya tanpa memotong.
Rumus. UX Research MVP = 3 aktivitas wajib: (1) User Persona 1–2 halaman, (2) User Journey Map satu alur utama, (3) Validasi prototipe dengan 5 pengguna nyata sebelum development sprint pertama.
User Persona
Persona bukan karakter fiksi untuk presentasi. Persona adalah ringkasan pola yang ditemukan dari wawancara dan observasi nyata. Setiap persona mencakup: demografi relevan (bukan nama dan foto stok sembarangan), tujuan utama, frustrasi terbesar, alur kerja saat ini, dan tingkat literasi teknologi.
Untuk aplikasi manajemen stok UMKM, misalnya, persona bisa berupa: Budi, pemilik toko kelontong 42 tahun — menggunakan WhatsApp untuk pencatatan stok, frustrasi saat stok habis tanpa pemberitahuan, terbiasa dengan smartphone Android entry-level, tidak terbiasa dengan terminologi teknis seperti “SKU” atau “varian produk”.
Persona berguna dalam satu cara konkret: setiap kali ada perdebatan desain, tanyakan “apakah Budi akan memahami ini?”. Bila tidak, desainnya perlu disederhanakan.
// Contoh struktur data Persona dalam proyek (TypeScript)
interface UserPersona {
id: string;
name: string;
role: string; // "Pemilik UMKM", "Admin Dinas", "Kasir"
ageRange: string; // "35-50"
techLiteracy: "low" | "medium" | "high";
primaryGoal: string;
topFrustrations: string[];
currentWorkflow: string; // cara kerja saat ini sebelum pakai aplikasi
devices: string[]; // "Android entry-level", "Laptop Windows"
keyQuote: string; // kutipan nyata dari wawancara
}
const budiPersona: UserPersona = {
id: "persona-pemilik-umkm",
name: "Budi Santoso",
role: "Pemilik Toko Kelontong",
ageRange: "38-50",
techLiteracy: "low",
primaryGoal: "Tahu stok habis sebelum pelanggan komplain",
topFrustrations: [
"Stok habis tiba-tiba saat ramai",
"Pencatatan di buku sering salah atau hilang",
"Tidak tahu produk mana yang paling laris",
],
currentWorkflow: "Tulis di buku tulis, foto dengan HP, kirim ke WhatsApp grup karyawan",
devices: ["Samsung A-series", "kadang tablet murah"],
keyQuote: "Kalau bisa tahu sendiri pas mau habis, itu udah cukup",
};
User Journey Mapping
User Journey Map adalah visualisasi langkah demi langkah yang dilakukan pengguna untuk mencapai satu tujuan — dari niat awal hingga hasil akhir. Untuk MVP, fokuskan pada satu alur inti terlebih dahulu: alur yang paling sering dilakukan dan paling berdampak bila bermasalah.
Setiap langkah dalam journey map mencatat: tindakan pengguna, pikiran/pertanyaan yang muncul, emosi (frustasi/senang/bingung), titik nyeri (pain point), dan peluang perbaikan. Ini bukan diagram yang cantik untuk dipajang — ini alat kerja untuk menemukan di mana aplikasi harus berfokus.
Information Architecture
Information Architecture (IA) adalah peta struktur informasi aplikasi — bagaimana konten dan fitur diorganisasi, diberi label, dan dinavigasi. Untuk MVP, IA bisa ditulis dalam bentuk daftar hierarki sebelum masuk ke desain visual.
Prinsip IA yang baik: setiap item harus ditemukan pengguna dalam maksimal tiga klik dari halaman mana pun. Bila sesuatu butuh empat klik atau lebih, letaknya salah. Pengguna tidak akan mencarinya — mereka akan menelepon support Anda.
# Contoh Information Architecture — Aplikasi Manajemen Toko
Dashboard
├── Ringkasan stok kritis
├── Transaksi hari ini
└── Grafik penjualan 7 hari
Produk
├── Daftar Produk (tabel, searchable)
│ ├── [Tambah] → modal kanan
│ ├── [Edit] → modal kanan
│ ├── [Detail] → slide panel kanan
│ └── [Hapus] → modal konfirmasi kecil
├── Kategori
└── Satuan
Transaksi
├── Penjualan
├── Pembelian (restok)
└── Riwayat
Laporan
├── Stok
├── Penjualan per Produk
└── Export CSV/PDF
Pengaturan
├── Profil Toko
├── Pengguna & Hak Akses
└── Integrasi
Validasi UX dengan User Testing
Validasi bukan meminta pendapat — validasi adalah mengamati pengguna nyata mengerjakan tugas nyata lalu mengukur apakah mereka berhasil tanpa bantuan. Ada dua metode paling praktis untuk MVP:
Hallway testing — minta 5 orang (teman, keluarga, kolega yang bukan tim) untuk menyelesaikan tiga tugas utama di prototipe Figma tanpa arahan. Catat di mana mereka berhenti, bertanya, atau salah klik. Ini cukup murah dan cepat.
Remote unmoderated testing — gunakan Maze.co atau UsabilityHub untuk membagikan tes ke pengguna target yang lebih representatif. Hasilnya berupa heatmap klik, durasi per tugas, dan tingkat keberhasilan. Untuk pasar Indonesia, Maze.co menawarkan akses ke panel pengguna lokal.
Tips. Rekam layar dan ekspresi pengguna saat testing. Video lima menit pengguna bingung di form yang Anda desain lebih meyakinkan stakeholder daripada sepuluh slide presentasi tentang UX.
Setelah validasi, buat daftar temuan berurutan prioritas: critical (pengguna tidak bisa menyelesaikan tugas sama sekali), major (pengguna berhasil tapi sangat kesulitan), minor (gangguan kecil). Perbaiki semua critical dan major sebelum development dimulai. Minor bisa masuk backlog.
Wireframing
Wireframe adalah kerangka visual aplikasi — tanpa warna, tanpa font cantik, tanpa gambar. Hanya struktur: di mana header, di mana navigasi, di mana konten utama, di mana tombol aksi. Wireframe yang baik bisa digambar di kertas dalam sepuluh menit dan menghemat seminggu perdebatan soal “tombolnya taruh di mana”.
Low-Fidelity vs High-Fidelity Wireframe
Low-fidelity (lo-fi) adalah sketsa kasar: kotak-kotak abu-abu, teks placeholder, panah penunjuk alur. Dibuat cepat, dibuang cepat, diiteras cepat. Tujuannya adalah mengkomunikasikan struktur dan alur, bukan estetika. Lo-fi cocok untuk fase eksplorasi ketika banyak ide masih berubah.
High-fidelity (hi-fi) mendekati tampilan akhir: warna nyata, tipografi benar, ikon sesungguhnya, spacing tepat. Hi-fi membutuhkan lebih banyak waktu dan lebih mahal untuk diubah. Buat hi-fi hanya setelah lo-fi divalidasi dan struktur ditetapkan.
Awas. Jangan langsung loncat ke hi-fi. Desainer yang terlalu cepat masuk Figma dengan detail warna dan tipografi akan defensif terhadap perubahan karena merasa sudah “banyak bekerja”. Lo-fi terlebih dahulu membebaskan semua pihak untuk berpikir tentang fungsi, bukan estetika.
Layout Standar: Sidebar Kiri + Konten Utama
Untuk aplikasi web berbasis data (admin panel, dashboard, SaaS, aplikasi dinas), layout paling teruji adalah kombinasi sidebar kiri untuk navigasi dan area konten utama untuk kerja. Layout ini intuitif karena mengikuti pola membaca kiri ke kanan, menempatkan konteks (menu) sebelum konten.
Komponen UI Reusable
Sebelum wireframing halaman demi halaman, identifikasi komponen yang akan muncul berulang. Mendefinisikan komponen lebih awal menghemat waktu desain dan memastikan konsistensi. Komponen utama yang hampir selalu ada di aplikasi CRUD:
- DataTable — tabel dengan sorting, pagination, search bawaan
- Modal — dialog dari kanan (form Tambah/Edit) dan dialog kecil tengah (konfirmasi Hapus)
- SlidePanel — panel geser dari kanan untuk Detail view
- FormField — input teks, dropdown, datepicker, file upload dengan label dan pesan error
- Badge/Chip — status label (Aktif/Nonaktif/Stok Kritis)
- Breadcrumb — penanda posisi navigasi
- EmptyState — tampilan saat tabel kosong, dengan ilustrasi dan tombol aksi
Tools Wireframe: Gratis dan Berbayar
| Tool | Tipe | Harga | Kelebihan | Kekurangan | Cocok untuk |
|---|---|---|---|---|---|
| Figma | Browser/Desktop | Gratis (starter) / $15/bln | Kolaborasi real-time, ekosistem plugin, prototyping | Lambat di koneksi lemah | Tim, startup, agency |
| Excalidraw | Browser/Self-host | Gratis sepenuhnya | Cepat, hand-drawn feel, open source | Tidak ada prototyping | Lo-fi cepat, sesi ideasi |
| Penpot | Browser/Self-host | Gratis (open source) | Open source, bisa self-host, SVG native | Ekosistem lebih kecil dari Figma | Tim yang ingin privasi data |
| Whimsical | Browser | Gratis terbatas / $10/bln | Flowchart + wireframe terintegrasi | Kurang fitur hi-fi | Alur kerja + wireframe lo-fi |
| Balsamiq | Desktop/Browser | $9/bln | Hand-drawn style, fokus lo-fi | Tidak ada prototyping interaktif | Presentasi konsep ke klien non-teknis |
| Adobe XD | Desktop | Gratis terbatas / $9.99/bln | Integrasi Creative Cloud | Adobe kurangi investasi di XD | Tim yang sudah di ekosistem Adobe |
| Kertas + Pena | Fisik | Gratis | Tercepat, zero overhead | Tidak bisa di-share digital langsung | Ideasi awal, solo |
Rumus. Gunakan Figma untuk semua wireframing tim. Gratis untuk dua editor, cukup untuk hampir semua MVP. Bila klien minta self-host karena data sensitif, pakai Penpot. Bila brainstorming cepat solo, Excalidraw atau kertas.
RUMUS UI WAJIB: Sidebar Kiri
Sidebar kiri adalah tulang punggung navigasi aplikasi web berbasis data. Bukan pilihan estetika — ini adalah keputusan arsitektur UI yang berdampak langsung pada kecepatan kerja pengguna, kemudahan skalabilitas fitur, dan konsistensi pengalaman di seluruh modul. Aplikasi yang menggunakan top navigation sering kali kehabisan ruang saat fitur bertambah; sidebar kiri tumbuh secara vertikal tanpa memecah tampilan.
Rumus. Setiap aplikasi CRUD multi-modul wajib menggunakan sidebar kiri dengan ikon + teks, collapsible menjadi icon-only, dan responsive collapse menjadi bottom navigation di mobile. Ini bukan opini desain — ini pola yang sudah divalidasi oleh ribuan aplikasi enterprise dan SaaS selama dua dekade.
Anatomi Sidebar Kiri
Sidebar kiri yang baik terdiri dari beberapa lapisan: logo/brand di atas, grup menu dengan label kategori, item menu dengan ikon dan teks, dan footer sidebar untuk pengaturan akun atau logout. Setiap item menu memiliki tiga state: normal (tidak aktif), hover, dan active (halaman saat ini).
Implementasi React + Tailwind
Berikut implementasi sidebar kiri yang mencakup collapsible state, active detection berdasarkan route, grup menu, dan responsive collapse ke bottom navigation di mobile:
// components/Sidebar.tsx
import { useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import {
Package, LayoutDashboard, ShoppingCart,
FileText, Settings, User, ChevronLeft, ChevronRight,
} from "lucide-react";
interface MenuItem {
label: string;
icon: React.ReactNode;
href: string;
badge?: number;
children?: { label: string; href: string }[];
}
const menuGroups: { title: string; items: MenuItem[] }[] = [
{
title: "Menu Utama",
items: [
{ label: "Dashboard", icon: <LayoutDashboard size={18}/>, href: "/dashboard" },
{ label: "Produk", icon: <Package size={18}/>, href: "/produk", badge: 3 },
{ label: "Transaksi", icon: <ShoppingCart size={18}/>, href: "/transaksi" },
{
label: "Laporan",
icon: <FileText size={18}/>,
href: "/laporan",
children: [
{ label: "Stok", href: "/laporan/stok" },
{ label: "Penjualan", href: "/laporan/penjualan" },
{ label: "Export", href: "/laporan/export" },
],
},
],
},
{
title: "Pengaturan",
items: [
{ label: "Pengaturan", icon: <Settings size={18}/>, href: "/settings" },
{ label: "Akun Saya", icon: <User size={18}/>, href: "/profile" },
],
},
];
export function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const location = useLocation();
const toggleGroup = (label: string) =>
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }));
const isActive = (href: string) =>
location.pathname === href || location.pathname.startsWith(href + "/");
return (
<>
{/* Desktop Sidebar */}
<aside
className={`hidden md:flex flex-col bg-[#0c1b3d] transition-all duration-300 ${
collapsed ? "w-16" : "w-56"
} h-screen sticky top-0 overflow-hidden`}
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-4 bg-[#1e3a8a]">
<div className="w-8 h-8 bg-amber-400 rounded-lg flex items-center justify-center text-[#0c1b3d] font-black shrink-0">
M
</div>
{!collapsed && (
<span className="text-white font-bold text-sm truncate">MyApp</span>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="ml-auto p-1 rounded text-slate-400 hover:text-white"
>
{collapsed ? <ChevronRight size={14}/> : <ChevronLeft size={14}/>}
</button>
</div>
{/* Menu */}
<nav className="flex-1 overflow-y-auto py-4 space-y-6">
{menuGroups.map((group) => (
<div key={group.title}>
{!collapsed && (
<p className="px-4 mb-1 text-[10px] font-bold text-slate-500 uppercase tracking-wider">
{group.title}
</p>
)}
<ul className="space-y-0.5 px-2">
{group.items.map((item) => (
<li key={item.href}>
{item.children ? (
<>
<button
onClick={() => !collapsed && toggleGroup(item.label)}
className={`w-full flex items-center gap-3 px-2 py-2 rounded-md text-sm transition-colors ${
isActive(item.href)
? "bg-[#1e3a8a] text-white"
: "text-slate-400 hover:text-white hover:bg-[#1e3a8a]/50"
}`}
>
<span className="shrink-0">{item.icon}</span>
{!collapsed && (
<>
<span className="flex-1 text-left">{item.label}</span>
<span className="text-xs">
{openGroups[item.label] ? "▾" : "›"}
</span>
</>
)}
</button>
{!collapsed && openGroups[item.label] && (
<ul className="ml-6 mt-0.5 space-y-0.5 border-l border-slate-700 pl-3">
{item.children.map((child) => (
<li key={child.href}>
<NavLink
to={child.href}
className={({ isActive }) =>
`block py-1.5 text-xs transition-colors ${
isActive
? "text-amber-400 font-semibold"
: "text-slate-400 hover:text-white"
}`
}
>
{child.label}
</NavLink>
</li>
))}
</ul>
)}
</>
) : (
<NavLink
to={item.href}
className={({ isActive }) =>
`flex items-center gap-3 px-2 py-2 rounded-md text-sm transition-colors relative ${
isActive
? "bg-[#1e3a8a] text-white before:absolute before:left-0 before:top-1 before:bottom-1 before:w-1 before:rounded-r before:bg-amber-400"
: "text-slate-400 hover:text-white hover:bg-[#1e3a8a]/50"
}`
}
>
<span className="shrink-0">{item.icon}</span>
{!collapsed && <span className="flex-1">{item.label}</span>}
{!collapsed && item.badge && (
<span className="bg-amber-400 text-[#0c1b3d] text-[10px] font-bold px-1.5 py-0.5 rounded-full">
{item.badge}
</span>
)}
</NavLink>
)}
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
{/* Mobile Bottom Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-[#0c1b3d] border-t border-[#1e3a8a] flex z-50">
{menuGroups[0].items.slice(0, 4).map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
`flex-1 flex flex-col items-center py-2 text-[10px] transition-colors ${
isActive ? "text-amber-400" : "text-slate-400"
}`
}
>
{item.icon}
<span className="mt-0.5">{item.label}</span>
</NavLink>
))}
<NavLink
to="/more"
className={({ isActive }) =>
`flex-1 flex flex-col items-center py-2 text-[10px] transition-colors ${
isActive ? "text-amber-400" : "text-slate-400"
}`
}
>
<span className="text-lg">⋯</span>
<span className="mt-0.5">Lainnya</span>
</NavLink>
</nav>
</>
);
}
Hierarki Menu: Group, Item, Submenu
Sidebar yang efektif menggunakan hierarki tiga tingkat: Group (kategori label besar seperti “Menu Utama” dan “Pengaturan”), Item (navigasi utama dengan ikon+teks), dan Submenu (daftar child item yang muncul saat item induk diklik, ditandai garis vertikal kiri). Jangan melampaui tiga tingkat ini — aplikasi dengan empat tingkat hierarki navigasi hampir selalu menandakan struktur fitur yang perlu direfaktor.
Active State dan Breadcrumb
Active state di sidebar berfungsi sebagai orientasi — pengguna harus selalu tahu sedang di mana. Gunakan kombinasi: background biru pada item aktif, garis tebal kuning di sisi kiri item, dan teks putih (berbeda dari teks abu-abu item nonaktif). Di area konten utama, breadcrumb di atas judul halaman memperkuat konteks: Dashboard › Produk › Edit Produk.
Tips. Saat sidebar dalam mode collapsed (icon-only), tampilkan tooltip teks item saat kursor hover di atasnya. Di React + Tailwind, gunakan grup Radix UI Tooltip atau cukup dengan
titleattribute + CSS tooltip custom. Ini memastikan pengguna tidak tersesat meski sidebar dikecilkan untuk menghemat ruang layar.
Responsive: Sidebar ke Bottom Nav Mobile
Di layar mobile (lebar di bawah 768px), sidebar kiri tidak fungsional — terlalu sempit untuk ditampilkan bersama konten utama. Solusinya adalah bottom navigation bar: strip horizontal di bawah layar yang menampilkan empat hingga lima item navigasi utama dengan ikon+teks pendek. Item sisanya dilipat ke menu “Lainnya”.
Gunakan Tailwind responsive prefix: kelas hidden md:flex menyembunyikan sidebar di mobile, dan md:hidden fixed bottom-0 menyembunyikan bottom nav di desktop. Tidak perlu JavaScript untuk switching — CSS media query sudah cukup.
CRUD In One Page dengan Modal Kanan
Pola CRUD paling produktif untuk aplikasi data-driven adalah seluruh operasi — tambah, lihat detail, edit, hapus — dilakukan tanpa meninggalkan halaman daftar. Pengguna tidak perlu membuka tab baru atau navigasi ke halaman terpisah. Tabel tetap terlihat di latar belakang. Modal atau panel geser muncul dari kanan. Saat selesai, modal ditutup dan tabel langsung diperbarui. Pola ini mempercepat alur kerja secara dramatis, terutama saat pengguna perlu memproses banyak record berturut-turut.
Rumus. CRUD one-page: (1) Tabel/list di konten utama, (2) Tombol Tambah → modal form dari KANAN (lebar 480–600px), (3) Klik baris/tombol Edit → modal form dari KANAN (data pre-filled), (4) Klik Detail → slide panel dari KANAN (read-only, lebih lebar 600–800px), (5) Hapus → modal konfirmasi kecil di tengah layar. Tidak ada navigasi halaman terpisah untuk operasi ini.
Mengapa Modal dari Kanan, Bukan Pindah Halaman?
Pindah halaman untuk setiap operasi CRUD memiliki biaya tersembunyi yang terakumulasi sepanjang hari kerja pengguna: waktu loading halaman baru, kehilangan konteks (filter/urutan tabel yang sebelumnya diset), dan gerakan mouse yang lebih jauh. Bila seorang admin memproses 50 produk dalam sehari, perpindahan halaman menambah ratusan klik dan menit yang terbuang.
Modal dari kanan (juga disebut drawer atau side sheet) menjawab semua masalah itu. Tabel tetap terlihat di belakang overlay semi-transparan — pengguna masih tahu konteksnya. Animasi geser memberikan transisi yang halus tanpa loading network. Dan saat modal ditutup, posisi scroll tabel tetap di tempat semula.
Pattern Tabel/List di Konten Utama
Tabel di konten utama adalah pusat kendali. Desainnya harus memudahkan tiga hal: menemukan record tertentu (via search + filter), memindai banyak record sekaligus (via kolom yang ringkas dan rapi), dan mengambil aksi cepat per record (via tombol aksi inline).
Kolom tabel MVP standar: checkbox bulk-select di kiri, kolom data utama (nama/judul), kolom atribut pendukung (2–4 kolom), kolom status badge, kolom tanggal dibuat/diperbarui, dan kolom aksi di kanan (Detail | Edit | Hapus). Jangan memasukkan terlalu banyak kolom — bila perlu scroll horizontal di laptop 1366px, berarti kolom terlalu banyak.
Implementasi React + Tailwind: CRUD One-Page Lengkap
// pages/ProdukPage.tsx — CRUD one-page lengkap
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { DataTable } from "@/components/DataTable";
import { RightDrawer } from "@/components/RightDrawer";
import { ProdukForm } from "@/components/ProdukForm";
import { ProdukDetail } from "@/components/ProdukDetail";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { produkApi } from "@/api/produk";
import type { Produk } from "@/types/produk";
type DrawerMode = "tambah" | "edit" | "detail" | null;
export default function ProdukPage() {
const queryClient = useQueryClient();
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
const [selected, setSelected] = useState<Produk | null>(null);
const [hapusTarget, setHapusTarget] = useState<Produk | null>(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// Fetch list produk
const { data, isLoading } = useQuery({
queryKey: ["produk", { search, page }],
queryFn: () => produkApi.list({ search, page, per_page: 20 }),
});
// Mutation: hapus
const hapusMutation = useMutation({
mutationFn: (id: string) => produkApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["produk"] });
setHapusTarget(null);
},
});
const openTambah = () => { setSelected(null); setDrawerMode("tambah"); };
const openEdit = (p: Produk) => { setSelected(p); setDrawerMode("edit"); };
const openDetail = (p: Produk) => { setSelected(p); setDrawerMode("detail"); };
const closeDrawer = () => { setDrawerMode(null); setSelected(null); };
const columns = [
{ key: "nama", header: "Nama Produk", sortable: true },
{ key: "kategori", header: "Kategori" },
{ key: "harga", header: "Harga Jual", render: (p: Produk) => `Rp ${p.harga.toLocaleString("id-ID")}` },
{ key: "stok", header: "Stok",
render: (p: Produk) => (
<span className={p.stok <= p.stok_minimal ? "text-red-500 font-bold" : ""}>
{p.stok} {p.stok <= p.stok_minimal && "⚠"}
</span>
),
},
{ key: "status", header: "Status",
render: (p: Produk) => (
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
p.is_aktif ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500"
}`}>
{p.is_aktif ? "Aktif" : "Nonaktif"}
</span>
),
},
{
key: "aksi", header: "Aksi",
render: (p: Produk) => (
<div className="flex gap-2 text-sm">
<button onClick={() => openDetail(p)} className="text-slate-500 hover:text-[#1e3a8a]">Detail</button>
<button onClick={() => openEdit(p)} className="text-[#1e3a8a] hover:underline">Edit</button>
<button onClick={() => setHapusTarget(p)} className="text-red-500 hover:underline">Hapus</button>
</div>
),
},
];
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<nav className="text-xs text-slate-400 mb-1">Dashboard › Produk</nav>
<h1 className="text-xl font-bold text-[#1e3a8a]">Daftar Produk</h1>
</div>
<button
onClick={openTambah}
className="bg-[#1e3a8a] text-white px-4 py-2 rounded-md text-sm font-semibold hover:bg-[#0c1b3d] transition-colors"
>
+ Tambah Produk
</button>
</div>
{/* Search */}
<input
type="text"
placeholder="Cari nama produk..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="mb-4 w-full max-w-xs px-3 py-2 border border-slate-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#1e3a8a]"
/>
{/* Table */}
<DataTable
columns={columns}
data={data?.items ?? []}
loading={isLoading}
total={data?.total ?? 0}
page={page}
onPageChange={setPage}
/>
{/* Right Drawer — Tambah / Edit */}
<RightDrawer
open={drawerMode === "tambah" || drawerMode === "edit"}
title={drawerMode === "tambah" ? "Tambah Produk Baru" : "Edit Produk"}
onClose={closeDrawer}
width="w-[520px]"
>
<ProdukForm
initialData={selected}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["produk"] });
closeDrawer();
}}
onCancel={closeDrawer}
/>
</RightDrawer>
{/* Right Drawer — Detail (lebih lebar) */}
<RightDrawer
open={drawerMode === "detail"}
title="Detail Produk"
onClose={closeDrawer}
width="w-[680px]"
>
{selected && (
<ProdukDetail
produk={selected}
onEdit={() => setDrawerMode("edit")}
onHapus={() => { setHapusTarget(selected); closeDrawer(); }}
/>
)}
</RightDrawer>
{/* Konfirmasi Hapus — modal kecil di tengah */}
<ConfirmDialog
open={!!hapusTarget}
title="Hapus Produk?"
description={`Produk "${hapusTarget?.nama}" akan dihapus. Tindakan ini tidak bisa dibatalkan.`}
confirmLabel="Ya, Hapus"
confirmVariant="danger"
loading={hapusMutation.isPending}
onConfirm={() => hapusTarget && hapusMutation.mutate(hapusTarget.id)}
onCancel={() => setHapusTarget(null)}
/>
</div>
);
}
Komponen RightDrawer
Komponen RightDrawer adalah wrapper reusable yang menangani animasi geser dari kanan, overlay gelap, dan penguncian scroll halaman saat terbuka. Ini dipakai oleh semua modul — bukan hanya produk.
// components/RightDrawer.tsx
import { useEffect } from "react";
import { X } from "lucide-react";
interface RightDrawerProps {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
width?: string; // "w-[480px]" | "w-[680px]"
}
export function RightDrawer({ open, title, onClose, children, width = "w-[520px]" }: RightDrawerProps) {
// Lock body scroll when open
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [open]);
// Close on Escape key
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex">
{/* Overlay */}
<div
className="flex-1 bg-black/40 backdrop-blur-[2px]"
onClick={onClose}
aria-hidden
/>
{/* Drawer panel */}
<div
className={`${width} max-w-full h-full bg-white shadow-2xl flex flex-col animate-in slide-in-from-right duration-300`}
role="dialog"
aria-modal
aria-label={title}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-[#1e3a8a] text-white shrink-0">
<h2 className="font-bold text-base">{title}</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/20 transition-colors"
aria-label="Tutup"
>
<X size={18}/>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto">
{children}
</div>
</div>
</div>
);
}
Modal Konfirmasi Hapus
Hapus adalah operasi destruktif yang butuh konfirmasi eksplisit. Gunakan modal kecil di tengah layar (bukan dari kanan) untuk membedakannya dari modal form. Ukurannya kecil — maksimal 400px lebar — dengan dua tombol jelas: Batal (di kiri, warna netral) dan Hapus (di kanan, warna merah). Tambahkan nama record yang akan dihapus di deskripsi agar pengguna yakin mereka memilih yang benar.
Awas. Jangan buat modal hapus yang hanya bertuliskan “Apakah Anda yakin?” tanpa menyebut nama item yang akan dihapus. Pengguna yang lelah sering mengklik Konfirmasi tanpa membaca, lalu menghapus data yang salah. Sertakan selalu nama/identitas spesifik record dalam teks konfirmasi.
Detail View & Slide Panel
Detail view adalah tampilan baca penuh dari satu record — semua atribut, riwayat perubahan, data terkait, dan tombol aksi. Dalam pola CRUD one-page, detail view ditampilkan melalui slide panel dari kanan yang lebih lebar dari modal form (biasanya 640–800px). Pengguna bisa melihat detail lengkap sambil tabel di belakang tetap terlihat, lalu langsung beralih ke Edit atau Hapus dari dalam panel tanpa menutupnya terlebih dahulu.
Struktur Slide Panel Detail
Panel detail yang baik terdiri dari empat zona: header dengan nama record dan tombol aksi utama (Edit, Hapus, Cetak, Share), area tab untuk mengelompokkan informasi, konten tab yang bisa di-scroll, dan tidak ada footer — aksi ada di header agar selalu terlihat meski pengguna sudah scroll jauh.
Tab di Dalam Panel
Tab memisahkan informasi yang banyak tanpa membuat halaman terlalu panjang. Konvensi tab dalam slide panel detail: Info (semua atribut utama record), Data Terkait (misalnya, produk ini ada di pesanan mana saja), Riwayat/Audit (siapa mengubah apa dan kapan), dan Dokumen atau Lampiran bila relevan. Jangan buat lebih dari lima tab — bila ada lebih dari lima kelompok informasi, pertimbangkan untuk memecah menjadi modul terpisah.
Activity Log di Detail Panel
Activity log adalah implementasi nyata audit trail yang terlihat oleh pengguna. Setiap perubahan pada record — siapa mengubah, field apa yang berubah, dari nilai berapa ke nilai berapa, dan kapan — tercatat dan ditampilkan secara kronologis terbalik (terbaru di atas). Ini bukan sekadar fitur estetika — ini menyelamatkan tim ketika ada data yang berubah tanpa sepengetahuan siapapun.
// components/ActivityLog.tsx
import { formatDistanceToNow } from "date-fns";
import { id as localeId } from "date-fns/locale";
interface AuditEntry {
id: string;
user_name: string;
action: "created" | "updated" | "deleted" | "restored";
changes: Record<string, { from: unknown; to: unknown }>;
created_at: string;
}
function formatChange(field: string, from: unknown, to: unknown): string {
const labels: Record<string, string> = {
nama: "Nama",
harga: "Harga Jual",
stok: "Stok",
is_aktif: "Status",
};
const label = labels[field] ?? field;
if (field === "harga") {
return `${label}: Rp ${Number(from).toLocaleString("id-ID")} → Rp ${Number(to).toLocaleString("id-ID")}`;
}
if (field === "is_aktif") {
return `${label}: ${from ? "Aktif" : "Nonaktif"} → ${to ? "Aktif" : "Nonaktif"}`;
}
return `${label}: "${from}" → "${to}"`;
}
export function ActivityLog({ entries }: { entries: AuditEntry[] }) {
return (
<div className="space-y-3 py-4 px-6">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider">Riwayat Perubahan</h3>
{entries.length === 0 && (
<p className="text-sm text-slate-400">Belum ada perubahan tercatat.</p>
)}
{entries.map((entry) => (
<div key={entry.id} className="flex gap-3">
<div className="w-7 h-7 rounded-full bg-[#eef2fb] flex items-center justify-center text-[#1e3a8a] text-xs font-bold shrink-0">
{entry.user_name.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm text-slate-700">
<span className="font-semibold">{entry.user_name}</span>{" "}
{entry.action === "created" && "menambahkan record ini"}
{entry.action === "deleted" && "menghapus record ini"}
{entry.action === "restored" && "memulihkan record ini"}
{entry.action === "updated" &&
Object.entries(entry.changes)
.map(([field, { from, to }]) => formatChange(field, from, to))
.join("; ")}
</p>
<p className="text-xs text-slate-400 mt-0.5">
{formatDistanceToNow(new Date(entry.created_at), { addSuffix: true, locale: localeId })}
</p>
</div>
</div>
))}
</div>
);
}
Tombol Aksi di Header Panel
Letakkan tombol Edit, Hapus, Cetak, dan Share di header panel — bukan di footer. Alasannya: pengguna mungkin sudah scroll jauh ke bawah membaca detail, dan harus scroll kembali ke atas untuk menemukan tombol. Dengan tombol di header yang sticky, aksi selalu terjangkau kapanpun. Di mobile, bila header terlalu sempit, gunakan tombol “...” (kebab menu) yang membuka dropdown berisi semua aksi.
Tips. Tambahkan shortcut keyboard untuk aksi umum di panel: tekan E untuk Edit, Delete untuk trigger konfirmasi Hapus, Escape untuk tutup panel. Ini terasa remeh tapi pengguna power yang memproses ratusan record per hari akan menghargainya.
Mockup & Hi-fi Design
Wireframe membuktikan struktur berfungsi. Mockup hi-fi membuktikan produk terlihat dan terasa profesional. Jarak antara keduanya adalah design system — seperangkat keputusan visual yang disepakati sekali, lalu diterapkan konsisten di seluruh aplikasi. Tanpa design system, desainer menghabiskan waktu menyelaraskan warna dan spacing yang sudah pernah diputuskan sebelumnya.
Dari Wireframe ke Mockup: Alur Kerja
Alur yang benar: (1) Lo-fi wireframe di kertas atau Excalidraw, (2) validasi struktur dengan stakeholder dan user testing singkat, (3) baru masuk Figma untuk hi-fi. Jangan desain hi-fi sebelum lo-fi disetujui. Setiap perubahan di hi-fi jauh lebih mahal (waktu desain ulang, update komponen, re-export asset).
Di Figma, organisasi file mockup yang baik: satu file per fitur besar, page terpisah untuk setiap alur (Login, Dashboard, Produk, Transaksi), frame diberi nama sesuai state (Produk - Default, Produk - Empty, Produk - Loading, Produk - Error). Gunakan Figma Auto Layout agar mockup responsif dan komponen bisa di-resize otomatis.
Design System untuk MVP
Design system MVP tidak perlu sekompleks design system Google Material atau Apple HIG. Yang wajib ada: palet warna (primary, secondary, accent, neutral, semantic), skala tipografi (heading H1–H4, body, caption, label), skala spacing (4px base: 4/8/12/16/24/32/48/64), set komponen inti (Button, Input, Badge, Table, Modal, Toast), dan aturan elevasi/shadow.
| Token | Nilai | Penggunaan |
|---|---|---|
--brand | #1e3a8a | Header, sidebar, tombol utama, link aktif |
--brand-dark | #0c1b3d | Sidebar background, teks di atas brand |
--accent | #f59e0b | Badge, highlight, CTA sekunder |
--tint | #eef2fb | Row hover, active state ringan, background tabel header |
--line | #d6def5 | Border tabel, divider, outline input |
--success | #10b981 | Status aktif, stok aman, konfirmasi berhasil |
--danger | #ef4444 | Hapus, stok kritis, error message |
--warning | #f59e0b | Peringatan, stok mendekati minimal |
--neutral-50 | #f8fafc | Background halaman |
--neutral-700 | #374151 | Teks body utama |
Dark Mode dan Light Mode
Dark mode bukan sekadar membalik warna. Warna yang bagus di light mode sering terlihat mencolok atau tidak terbaca di dark mode. Pendekatan yang benar: definisikan semantic color tokens (bukan hard-coded hex), lalu override token tersebut di dark mode menggunakan CSS custom properties atau Tailwind dark variant.
/* design-tokens.css — Semantic tokens untuk light/dark mode */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-sidebar: #0c1b3d;
--text-primary: #111827;
--text-secondary:#6b7280;
--text-muted: #94a3b8;
--border-default:#d6def5;
--brand-primary: #1e3a8a;
--brand-accent: #f59e0b;
--surface-raised:#ffffff; /* kartu, modal */
--shadow-md: 0 4px 16px rgba(0,0,0,0.08);
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-sidebar: #020817;
--text-primary: #f1f5f9;
--text-secondary:#94a3b8;
--text-muted: #475569;
--border-default:#1e293b;
--brand-primary: #3b82f6; /* lebih terang di dark bg */
--brand-accent: #fbbf24;
--surface-raised:#1e293b;
--shadow-md: 0 4px 16px rgba(0,0,0,0.4);
}
/* Toggle dark mode dengan JavaScript:
document.documentElement.dataset.theme = "dark" */
Animasi dan Transisi
Animasi yang baik memperjelas apa yang baru terjadi — bukan sekadar terlihat keren. Prinsip: masuk lambat, keluar cepat. Modal geser dari kanan muncul dalam 300ms (cukup untuk pengguna menyadari perubahan), tapi menghilang dalam 150ms (tidak membuat pengguna menunggu). Daftar yang diperbarui setelah simpan: item baru muncul dengan fade+slide singkat sehingga mata pengguna tertuju ke item terbaru.
Hindari animasi yang tidak memberi informasi: efek berputar tanpa tujuan, logo bouncing, transisi halaman yang terlalu dramatis. Setiap animasi harus menjawab pertanyaan: “apakah ini membantu pengguna memahami apa yang baru terjadi?”
Mockup untuk Pitching Klien
Mockup yang dipakai untuk pitching klien berbeda tujuannya dari mockup untuk developer. Untuk pitching: tampilkan mockup dalam device frame (laptop atau smartphone mockup di Figma), pilih data dummy yang realistis dan relevan konteks klien (bukan “Lorem ipsum” atau “Produk 1”), animasikan prototipe interaktif Figma untuk alur kunci, dan ekspor sebagai PDF presentasi serta link prototipe yang bisa diklik klien sendiri di ponselnya.
Tips. Saat presentasi mockup ke klien UMKM atau dinas, jangan tampilkan Figma langsung — mereka akan teralihkan melihat layer panel dan properti. Pakai mode Present Figma (URL share dengan ?mode=presentation) atau ekspor frame sebagai PNG yang ditaruh di slide presentasi PowerPoint/Google Slides yang mereka kenal.
Frontend Framework Lengkap
Memilih frontend framework bukan sekadar soal teknologi yang paling populer bulan ini. Ini keputusan yang menentukan kecepatan development, kemudahan rekrutmen, biaya perawatan jangka panjang, dan kemampuan aplikasi berkembang. Framework yang salah untuk konteks yang salah bisa menjadi beban teknis yang menghabiskan waktu tim selama bertahun-tahun. Bab ini membahas setiap pilihan utama secara jujur, dengan konteks nyata penggunaan di Indonesia.
React (dengan Next.js atau Remix)
React adalah library UI paling populer di dunia dengan ekosistem terbesar. Ia bukan framework — hanya lapisan UI yang menangani rendering komponen. Untuk aplikasi nyata, React hampir selalu dipakai bersama framework: Next.js untuk SSR/SSG/full-stack, atau Remix untuk form-heavy app dengan loading state yang elegan.
Next.js adalah pilihan aman untuk hampir semua kasus enterprise Indonesia: dokumentasi luar biasa, ekosistem plugin besar, hosting mudah di Vercel, dukungan App Router untuk RSC (React Server Components) yang mengurangi JavaScript di browser. Kekurangannya: konfigurasi bisa kompleks untuk tim kecil yang baru belajar React.
Remix unggul di aplikasi dengan banyak form dan mutasi data — seperti aplikasi manajemen internal, admin panel, atau aplikasi dinas yang form-heavy. Remix mengelola loading dan error state secara native di level route, mengurangi boilerplate yang biasanya ditulis manual.
// Contoh Next.js App Router — Server Component untuk daftar produk
// app/produk/page.tsx (dieksekusi di server, zero client JS untuk render awal)
import { produkService } from "@/services/produk";
import { ProdukClient } from "./ProdukClient"; // Client Component untuk interaktivitas
interface SearchParams { search?: string; page?: string; }
export default async function ProdukPage({ searchParams }: { searchParams: SearchParams }) {
// Fetch langsung di server — tidak ada loading state, tidak ada useEffect
const { items, total, per_page } = await produkService.list({
search: searchParams.search ?? "",
page: Number(searchParams.page ?? 1),
});
return (
<div className="p-6">
<h1 className="text-xl font-bold text-[#1e3a8a] mb-4">Daftar Produk</h1>
{/* Pass server data ke client component */}
<ProdukClient initialData={{ items, total, per_page }} />
</div>
);
}
// Metadata halaman (SEO) — gratis di Next.js App Router
export const metadata = {
title: "Produk — MyApp",
description: "Kelola daftar produk toko Anda",
};
Vue.js (dengan Nuxt)
Vue adalah alternatif React yang lebih mudah dipelajari, terutama untuk developer yang datang dari background jQuery atau PHP template. Sintaks template Vue sangat intuitif dan dekat dengan HTML biasa. Nuxt adalah framework Vue yang setara dengan Next.js — SSR, auto-routing, plugin ekosistem.
Vue sangat populer di komunitas developer Asia, termasuk Indonesia. Banyak proyek dinas dan UMKM dibangun dengan Vue karena developer-nya lebih mudah ditemukan di kisaran gaji junior-menengah. Composition API (Vue 3) membuat Vue semakin matang untuk aplikasi skala enterprise.
Kapan memilih Vue: tim kecil (2–5 orang) dengan developer yang belum terbiasa ekosistem React/TypeScript, proyek yang butuh iterasi cepat dengan template HTML yang familiar, atau proyek dinas yang dikerjakan vendor lokal dengan budget developer terbatas.
Angular
Angular adalah framework penuh dari Google — bukan hanya library UI, melainkan solusi lengkap termasuk routing, state management, HTTP client, form handling, dan testing tooling. Ini membuatnya ideal untuk enterprise besar dengan tim besar karena strukturnya yang opinionated memaksa konsistensi kode.
Angular menggunakan TypeScript secara native dan wajib (bukan opsional). Learning curve-nya paling curam di antara semua pilihan dalam daftar ini. Namun untuk organisasi besar seperti bank, perusahaan telekomunikasi, atau instansi pemerintah pusat yang butuh standarisasi kode di antara puluhan developer, Angular adalah pilihan yang sangat masuk akal.
Jangan pilih Angular untuk: startup tahap awal, tim di bawah 5 orang, atau proyek yang timeline-nya ketat dan tim belum berpengalaman dengan Angular.
Svelte dan SvelteKit
Svelte mengambil pendekatan berbeda: tidak ada Virtual DOM, tidak ada runtime besar. Svelte adalah kompiler — kode Svelte dikompilasi menjadi JavaScript vanilla yang sangat efisien. Hasilnya: bundle size paling kecil di antara semua framework populer, startup time aplikasi sangat cepat, dan kode yang lebih ringkas.
SvelteKit adalah framework fullstack Svelte (setara Next.js untuk React). Developer yang pernah memakai React atau Vue biasanya produktif dengan Svelte dalam beberapa hari — sintaksnya bersih dan tidak ada boilerplate berlebihan.
Tantangan Svelte: ekosistem library UI component-nya jauh lebih kecil dari React. Bila Anda mengandalkan library seperti React Table, React Query, atau komponen form kompleks pihak ketiga, migrasinya tidak mudah. Svelte cocok untuk aplikasi baru yang tim bersedia membangun komponen sendiri atau memakai solusi yang lebih minimalis.
Solid.js
Solid.js menawarkan API mirip React (JSX, hooks-like signals) tapi dengan performa mendekati Svelte karena juga berbasis kompilasi. Ini pilihan menarik untuk developer yang suka React tapi ingin performa lebih baik. Namun ekosistemnya masih sangat kecil dibanding React, dan rekrutmen developer Solid.js hampir mustahil di pasar Indonesia 2025. Cocok untuk eksperimen atau proyek sampingan, belum untuk produk komersial yang butuh perawatan tim.
jQuery
jQuery bukan framework modern — ia adalah library DOM manipulation yang dominan era 2010-an. Di 2025, jQuery masih relevan dalam dua konteks: (1) maintenance warisan kode lama yang belum bisa dimigrasi, dan (2) penambahan interaktivitas ringan ke halaman server-rendered (Laravel Blade, Django templates, PHP murni) tanpa build tool. Jangan pilih jQuery untuk aplikasi baru dengan UI kompleks — Anda akan menulis lebih banyak kode dan lebih sulit merawatnya.
Alpine.js
Alpine.js adalah jQuery-modern: library ringan (14KB) untuk menambahkan interaktivitas ke HTML yang di-render server. Cocok dipakai bersama Laravel Blade atau Livewire — toggle dropdown, show/hide element, form validation sederhana, tanpa perlu build tool atau bundler. Untuk aplikasi CRUD internal yang di-render dari PHP/Python, Alpine.js + sedikit HTML attribute lebih sederhana dari React penuh.
| Framework | Bundle Size (gzip) | Learning Curve | Ekosistem | Popularitas di Indonesia | Gaji Developer (Jkt, 2025) | Terbaik untuk |
|---|---|---|---|---|---|---|
| React + Next.js | ~45KB React core | Menengah | Sangat besar | Sangat tinggi | Rp 8–30 jt/bln | SaaS, startup, enterprise, aplikasi data-heavy |
| Vue + Nuxt | ~34KB Vue core | Rendah-Menengah | Besar | Tinggi | Rp 6–22 jt/bln | UMKM, dinas, tim kecil, iterasi cepat |
| Angular | ~75KB+ (full) | Tinggi | Besar (Google) | Menengah | Rp 12–40 jt/bln | Enterprise besar, bank, telco, instansi pemerintah |
| Svelte + SvelteKit | ~10KB compiled | Rendah | Kecil-Menengah | Rendah | Rp 7–20 jt/bln | Performa kritis, produk baru, tim yang mau belajar |
| Solid.js | ~7KB | Rendah-Menengah | Kecil | Sangat rendah | Sulit rekrut | Eksperimen, proyek sampingan |
| Alpine.js | ~14KB | Sangat rendah | Kecil | Menengah | Rp 4–12 jt/bln | Server-rendered app (Laravel/Django), interaktivitas ringan |
| jQuery | ~30KB | Sangat rendah | Besar (legacy) | Menurun | Rp 4–10 jt/bln | Maintenance legacy, augment server-rendered sederhana |
Rumus. Untuk MVP aplikasi data-driven baru di Indonesia 2025: pilih React + Next.js bila tim punya developer React atau bersedia belajar — ekosistemnya menjamin tersedianya solusi untuk setiap masalah. Pilih Vue + Nuxt bila tim lebih familiar Vue atau klien adalah UMKM/dinas yang developer lokalnya lebih banyak Vue. Jangan pilih framework berdasarkan yang paling sering trending di Twitter.
Pertimbangan Ekstra: TypeScript
Terlepas dari framework mana yang dipilih, gunakan TypeScript. TypeScript mendeteksi kesalahan type saat coding, bukan saat runtime di production. Untuk aplikasi CRUD dengan banyak model data (Produk, Transaksi, Pengguna, Laporan), TypeScript menghemat berjam-jam debugging yang disebabkan oleh undefined tidak terduga, field yang salah nama, atau respons API yang berubah shape-nya. Semua framework di atas mendukung TypeScript penuh.
CSS Framework & Styling
Styling adalah salah satu keputusan teknis yang paling mudah menimbulkan perpecahan pendapat dalam tim frontend. Ada yang bersumpah dengan Tailwind, ada yang mempertahankan Bootstrap, ada yang menganggap semua utility-first CSS tidak terstruktur. Faktanya, pilihan styling bergantung pada skala tim, kecepatan iterasi yang dibutuhkan, dan apakah Anda membangun design system sendiri atau memakai komponen jadi. Bab ini membahas pilihan utama secara proporsional dan jujur.
Tailwind CSS: Rekomendasi Utama
Tailwind adalah utility-first CSS framework — bukan kumpulan komponen siap pakai, melainkan seperangkat class kecil yang masing-masing melakukan satu hal (padding, margin, warna, flex, grid). Anda membangun UI dengan menggabungkan class tersebut langsung di HTML/JSX.
Keunggulan Tailwind: tidak ada CSS custom yang tumpang tindih, tidak ada specificity war, bundle CSS akhir sangat kecil (Tailwind hanya menyertakan class yang benar-benar dipakai via PurgeCSS/JIT), dan konsistensi warna/spacing dijamin via konfigurasi design token terpusat di tailwind.config.ts. Bila Anda menyebutkan warna bg-[#1e3a8a] atau spacing px-6 py-4, setiap developer di tim akan menghasilkan output visual yang sama.
Kelemahan Tailwind: class HTML bisa panjang dan terlihat berantakan bagi yang pertama kali melihatnya. Untuk komponen yang dipakai berulang, encapsulate ke React/Vue component — bukan karena class-nya panjang di JSX, melainkan karena komponen memang perlu encapsulasi untuk reusability.
// tailwind.config.ts — Konfigurasi design token terpusat
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{ts,tsx,html}"],
darkMode: ["selector", '[data-theme="dark"]'],
theme: {
extend: {
colors: {
brand: {
DEFAULT: "#1e3a8a",
dark: "#0c1b3d",
light: "#3b5cc4",
},
accent: {
DEFAULT: "#f59e0b",
dark: "#d97706",
},
tint: "#eef2fb",
line: "#d6def5",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Consolas", "monospace"],
},
boxShadow: {
"card": "0 1px 4px rgba(30,58,138,0.07)",
"modal": "0 8px 32px rgba(0,0,0,0.18)",
"drawer": "-4px 0 24px rgba(0,0,0,0.12)",
},
borderRadius: {
"card": "10px",
},
},
},
plugins: [
require("@tailwindcss/forms"), // styling bawaan form element
require("@tailwindcss/typography"), // prose content
require("tailwindcss-animate"), // animasi slide/fade untuk drawer
],
};
export default config;
Bootstrap
Bootstrap adalah CSS framework berbasis komponen yang paling lama eksis dan paling dikenal developer dunia. Ia menyediakan komponen siap pakai: grid 12 kolom, navbar, modal, badge, form, card, alert. Untuk developer yang ingin hasil cepat tanpa konfigurasi, Bootstrap masih relevan — terutama Bootstrap 5 yang sudah tidak bergantung jQuery.
Kelemahannya: semua aplikasi Bootstrap terlihat mirip satu sama lain bila tidak dikustomisasi. Mengoverride style Bootstrap yang sangat spesifik (class Bootstrap dengan specificity tinggi) bisa memicu frustrasi. Untuk aplikasi yang butuh design unik dan konsisten dengan brand klien, Bootstrap membutuhkan usaha kustomisasi yang tidak jauh berbeda dengan membangun dari nol dengan Tailwind.
Kapan Bootstrap masuk akal: proyek internal yang tidak butuh brand unik (tool admin cepat untuk kebutuhan internal tim), developer junior yang belum familiar Tailwind, atau project yang butuh prototype dalam satu hari.
Component Library: Material UI, Chakra UI, shadcn/ui
Component library berbeda dari CSS framework — mereka menyediakan komponen React/Vue yang sudah jadi beserta logic-nya (accessibility, keyboard navigation, focus management), bukan hanya styling.
Material UI (MUI) adalah implementasi Google Material Design untuk React. Komponen sangat lengkap dan sudah memenuhi aksesibilitas WCAG. Cocok untuk aplikasi enterprise yang tidak membutuhkan brand custom, atau tim yang tidak punya desainer. Kekurangannya: terlihat sangat “Google”, kustomisasi theme bisa verbose.
Chakra UI lebih modular dan mudah dikustomisasi dari MUI. Component API-nya bersih dan didokumentasikan dengan baik. Cocok untuk startup yang butuh komponen jadi tapi ingin branding unik.
shadcn/ui adalah pendekatan berbeda: bukan library yang diinstall sebagai package, melainkan koleksi komponen yang Anda copy langsung ke codebase Anda. Komponen-nya dibangun di atas Radix UI (aksesibilitas) + Tailwind (styling). Karena kode ada di tangan Anda, kustomisasi sangat bebas. Ini pilihan terbaik untuk aplikasi baru yang menggunakan Tailwind — bisa mulai dengan komponen matang dan sesuaikan sebebasnya.
// Contoh penggunaan shadcn/ui Button + Dialog
// Setelah: npx shadcn@latest add button dialog
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function HapusProdukDialog({ produk, onConfirm }: { produk: Produk; onConfirm: () => void }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">Hapus</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Hapus Produk?</DialogTitle>
<DialogDescription>
Produk <strong>{produk.nama}</strong> akan dihapus permanen.
Tindakan ini tidak dapat dibatalkan.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Batal</Button>
<Button variant="destructive" onClick={onConfirm}>
Ya, Hapus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
CSS Modules vs Styled-Components vs Inline Style
CSS Modules adalah file .module.css yang class-nya di-scope otomatis ke komponen yang menggunakannya — tidak ada collision antar komponen. Cocok dipakai bersama Next.js yang mendukungnya native. Kekurangan: tidak ada TypeScript auto-complete untuk nama class, dan tidak bisa menggunakan design token dinamis dengan mudah.
Styled-components dan Emotion adalah CSS-in-JS: Anda menulis CSS dalam template literal JavaScript, dan style dikaitkan langsung ke komponen. Kelebihan: bisa menggunakan props untuk styling dinamis dengan sangat elegan. Kekurangan: performa runtime (style digenerate saat JavaScript dieksekusi), dan di React Server Components, CSS-in-JS traditional tidak bisa digunakan tanpa workaround.
Inline style adalah escape hatch, bukan pendekatan utama. Gunakan hanya untuk nilai yang benar-benar dinamis dan tidak bisa diekspresikan dengan class (misalnya, lebar progress bar berdasarkan persen dari data: style={{ width: `${persen}%` }}).
Pola Responsive Utama
Setiap halaman di aplikasi harus berfungsi di tiga breakpoint minimum: mobile (375px–767px), tablet (768px–1023px), dan desktop (1024px+). Pola responsive di Tailwind menggunakan prefix: sm: (640px), md: (768px), lg: (1024px), xl: (1280px).
/* Pola responsive umum untuk layout CRUD */
/* Layout utama: sidebar + konten */
.layout {
/* Mobile: konten penuh, sidebar tersembunyi (bottom nav) */
/* Desktop: grid dengan sidebar 224px + konten fleksibel */
}
/* Tailwind equivalent: */
/* <div className="flex flex-col md:flex-row min-h-screen"> */
/* <Sidebar /> → hidden di mobile, flex di md+ */
/* <main className="flex-1 overflow-auto"> */
/* Tabel responsif: horizontal scroll di mobile */
/* <div className="overflow-x-auto"> */
/* <table className="min-w-[640px] w-full"> */
/* Modal/Drawer responsif: full-screen di mobile */
/* <div className="w-full md:w-[520px] h-full"> */
/* Grid dashboard: 1 kolom mobile, 2 tablet, 4 desktop */
/* <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4"> */
| Pendekatan Styling | Ukuran Bundle | Kustomisasi | TypeScript DX | Server Component | Rekomendasi |
|---|---|---|---|---|---|
| Tailwind CSS | Sangat kecil (JIT) | Sangat tinggi | Plugin IDE tersedia | Penuh | Utama — semua proyek baru |
| shadcn/ui + Tailwind | Kecil | Sangat tinggi (copy kode) | Sangat baik | Penuh | Komponen siap + Tailwind |
| Bootstrap 5 | ~22KB gzip | Menengah (SCSS variables) | Baik | Penuh | Prototype cepat / internal tools |
| Material UI (MUI) | ~100KB+ tree-shaken | Menengah (theme) | Sangat baik | Terbatas (beta) | Enterprise tanpa desainer |
| CSS Modules | Sangat kecil | Penuh (CSS manual) | Menengah | Penuh | Proyek Next.js tanpa Tailwind |
| Styled-components | Runtime overhead | Sangat tinggi | Sangat baik | Tidak (RSC) | Proyek Pages Router lama |
Rumus. Gunakan Tailwind CSS sebagai basis styling untuk semua proyek baru. Tambahkan shadcn/ui untuk komponen interaktif matang (Dialog, Dropdown, Tooltip, Select) yang sudah menangani aksesibilitas. Hasilnya: design system yang bisa dikustomisasi penuh, bundle kecil, dan tidak ada konflik CSS. Hindari mencampur terlalu banyak pendekatan styling dalam satu proyek — seragamkan pilihan sejak hari pertama.
Backend & API Lengkap
Backend Framework & Bahasa Pemrograman
Memilih bahasa dan framework backend adalah keputusan yang akan hidup bersama proyek Anda bertahun-tahun. Tidak ada pilihan yang mutlak benar — setiap bahasa lahir untuk memecahkan masalah tertentu, dan setiap framework membawa pendapat tentang cara terbaik membangun aplikasi. Yang berbahaya adalah memilih berdasarkan hype, bukan berdasarkan kecocokan tim, konteks bisnis, dan beban teknis jangka panjang yang bersedia Anda tanggung.
Bab ini membahas sepuluh ekosistem backend yang paling relevan di Indonesia saat ini — dari Java yang mendominasi perbankan nasional hingga Rust yang mulai menembus kebutuhan sistem kritis. Untuk setiap ekosistem, kita akan bedah kelebihan, kekurangan, dan konteks penggunaan yang paling tepat, dilengkapi contoh kode CRUD nyata agar pembanding menjadi konkret, bukan sekadar opini.
Rumus. Pilih bahasa berdasarkan tiga faktor: (1) tim Anda sudah bisa apa, (2) ekosistem library apa yang tersedia untuk domain masalah Anda, (3) rekrutmen di kota Anda seperti apa. Faktor keempat baru performa — dan itu jarang jadi bottleneck di tahap MVP.
Java — Spring Boot
Ekosistem & Konteks
Java adalah bahasa backend paling tua yang masih dominan di segmen enterprise Indonesia. Hampir seluruh bank nasional, BUMN besar, dan lembaga keuangan menjalankan core sistem mereka di Java. Ekosistem Spring Framework — terutama Spring Boot yang memudahkan konfigurasi — telah matang sejak 2003 dan punya solusi siap pakai untuk hampir setiap kebutuhan enterprise: Spring Security untuk autentikasi, Spring Data JPA untuk ORM, Spring Batch untuk pemrosesan massal, Spring Cloud untuk microservices.
Kelebihan: Ekosistem paling lengkap untuk kebutuhan enterprise — transaksi terdistribusi, connection pooling, message queue, batch job semuanya punya solusi first-class. JVM modern (terutama GraalVM native image) sudah sangat efisien. Komunitas Indonesia besar, rekrutmen mudah untuk level senior. Tipe statis membuat refaktor skala besar lebih aman. Spring Security adalah salah satu implementasi keamanan paling battle-tested.
Kekurangan: Boilerplate tinggi — kode Java untuk hal sederhana bisa tiga kali lebih panjang dari Python atau Go. Waktu startup aplikasi lambat bila tidak menggunakan native compilation. Learning curve tinggi untuk pemula. Maven/Gradle dan ekosistem dependency yang besar membuat build time lama. Bukan pilihan tepat untuk tim kecil yang ingin iterasi cepat.
Cocok untuk: Sistem perbankan, fintech yang butuh ISO 8583, integrasi dengan sistem BUMN, aplikasi government yang wajib audit keamanan ketat, tim 5+ developer dengan latar belakang enterprise.
// Spring Boot — Hello CRUD: Produk Controller
// File: src/main/java/com/app/controller/ProdukController.java
@RestController
@RequestMapping("/api/produk")
@RequiredArgsConstructor
public class ProdukController {
private final ProdukService produkService;
@GetMapping
public ResponseEntity<Page<ProdukDto>> index(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String q) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return ResponseEntity.ok(produkService.findAll(q, pageable));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProdukDto store(@Valid @RequestBody ProdukRequest req) {
return produkService.create(req);
}
@GetMapping("/{id}")
public ProdukDto show(@PathVariable Long id) {
return produkService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Produk tidak ditemukan"));
}
@PutMapping("/{id}")
public ProdukDto update(@PathVariable Long id,
@Valid @RequestBody ProdukRequest req) {
return produkService.update(id, req);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void destroy(@PathVariable Long id) {
produkService.softDelete(id); // WAJIB soft delete
}
}
PHP — Laravel
Ekosistem & Konteks
Laravel adalah raja backend di segmen UMKM, startup tahap awal, dan aplikasi dinas pemerintah tingkat kabupaten/kota di Indonesia. Popularitasnya didorong oleh bahasa PHP yang sudah sangat familiar (hampir semua developer web Indonesia menyentuh PHP sejak awal belajar), ekosistem hosting murah (shared hosting Rp20.000/bulan sudah cukup untuk MVP kecil), dan dokumentasi Laravel yang luar biasa lengkap dalam bahasa Inggris yang mudah dipahami.
Kelebihan: Development speed tertinggi untuk tim kecil — fitur lengkap out of the box (Eloquent ORM, artisan CLI, queue, broadcasting, storage). Ekosistem Filament dan Livewire memungkinkan admin panel tanpa JavaScript framework. Hosting murah dan tersedia di mana-mana. Komunitas Indonesia terbesar — grup Facebook Laravel Indonesia punya ratusan ribu anggota. Cycle time dari ide ke deployment bisa hitungan jam untuk fitur sederhana.
Kekurangan: Performa lebih rendah dari Go atau Rust untuk beban tinggi — walau Laravel Octane dengan Swoole/RoadRunner sudah memperbaiki ini signifikan. PHP masih membawa stigma “bahasa amatir” di komunitas tertentu walau PHP 8.x sudah sangat modern. Konsistensi arsitektur bergantung berat pada disiplin tim — Laravel sangat permisif sehingga kode cepat menjadi spaghetti bila tidak ada code review ketat.
Cocok untuk: Aplikasi dinas pemerintah tingkat kab/kota, sistem manajemen UMKM, portal berita, sistem absensi sekolah, startup yang butuh launch cepat dengan tim 1–4 orang.
<?php
// Laravel — Hello CRUD: ProdukController
// File: app/Http/Controllers/ProdukController.php
namespace App\Http\Controllers;
use App\Http\Requests\ProdukRequest;
use App\Http\Resources\ProdukResource;
use App\Models\Produk;
use Illuminate\Http\Request;
class ProdukController extends Controller
{
public function index(Request $request)
{
$produk = Produk::query()
->when($request->q, fn ($q, $search) =>
$q->where('nama', 'like', "%{$search}%"))
->latest()
->paginate(20);
return ProdukResource::collection($produk);
}
public function store(ProdukRequest $request)
{
$produk = Produk::create($request->validated() + [
'created_by' => auth()->id(),
]);
return new ProdukResource($produk);
}
public function show(Produk $produk)
{
return new ProdukResource($produk);
}
public function update(ProdukRequest $request, Produk $produk)
{
$produk->update($request->validated() + [
'updated_by' => auth()->id(),
]);
return new ProdukResource($produk);
}
public function destroy(Produk $produk)
{
// Eloquent SoftDeletes otomatis mengisi deleted_at
$produk->update(['deleted_by' => auth()->id()]);
$produk->delete();
return response()->noContent();
}
}
PHP — WordPress (REST API / Headless)
Ekosistem & Konteks
WordPress bukan sekadar CMS — sejak versi 4.7 WordPress memiliki REST API built-in yang memungkinkannya digunakan sebagai headless backend. Untuk toko online sederhana, profil bisnis, atau portal konten, WordPress dengan WooCommerce dan ACF (Advanced Custom Fields) bisa menjadi backend yang sangat cepat dibangun tanpa menulis kode dari nol.
Kelebihan: Time-to-market tercepat untuk konten website — tidak perlu menulis sistem manajemen konten dari nol. Ekosistem plugin ribuan — SEO, pembayaran, email marketing semuanya tersedia. Non-developer bisa mengelola konten tanpa bantuan teknis. Hosting WordPress tersedia mulai Rp30.000/bulan.
Kekurangan: Bukan pilihan untuk aplikasi dengan logika bisnis kompleks. Keamanan bergantung pada update plugin yang konsisten — plugin yang tidak terawat adalah vektor serangan umum. Performa buruk bila konfigurasi caching tidak benar. Debt teknis menumpuk cepat bila membangun fitur custom di atas WordPress.
Cocok untuk: Website profil toko/perusahaan, toko online sederhana (WooCommerce), blog institusi, portal berita komunitas, landing page dengan form.
JavaScript / TypeScript — Node.js, Express, NestJS, Hono
Ekosistem & Konteks
Ekosistem Node.js adalah yang paling dinamis — dan paling terfragmentasi. Express.js adalah micro-framework yang hanya menyediakan routing dan middleware, sangat fleksibel tapi membutuhkan keputusan arsitektur sendiri. NestJS adalah opinionated framework yang mengadopsi pola Angular (modul, controller, service, decorator) dan sangat cocok untuk tim yang ingin struktur jelas. Hono adalah framework ultra-ringan yang berjalan di Edge Runtime (Cloudflare Workers, Deno Deploy) — sangat cepat dan modern.
Kelebihan: Bahasa yang sama antara frontend dan backend mengurangi context switching. npm adalah ekosistem package terbesar di dunia. Non-blocking I/O membuat Node.js sangat efisien untuk aplikasi yang I/O-bound (baca/tulis database, API calls). TypeScript memberikan type safety tanpa kehilangan fleksibilitas JavaScript. NestJS memberikan struktur yang mirip Spring Boot tanpa boilerplate Java.
Kekurangan: Callback hell dan async/await bug masih jebakan umum pemula. Ekosistem bergerak sangat cepat — library yang populer tahun lalu bisa sudah ditinggalkan. Single-threaded nature membuat Node.js kurang cocok untuk CPU-intensive tasks. Kualitas package npm sangat bervariasi — ada yang sangat baik, ada yang abandonware berbahaya.
Cocok untuk: Aplikasi real-time (chat, notifikasi live), BFF (Backend for Frontend) layer, API gateway, startup mid-range dengan tim fullstack, aplikasi yang butuh iterasi cepat.
// NestJS + TypeScript — Hello CRUD: Produk Module
// File: src/produk/produk.controller.ts
import {
Controller, Get, Post, Put, Delete,
Param, Body, Query, HttpCode, HttpStatus,
UseGuards, ParseIntPipe,
} from '@nestjs/common';
import { ProdukService } from './produk.service';
import { CreateProdukDto } from './dto/create-produk.dto';
import { UpdateProdukDto } from './dto/update-produk.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@Controller('produk')
@UseGuards(JwtAuthGuard)
export class ProdukController {
constructor(private readonly produkService: ProdukService) {}
@Get()
findAll(
@Query('page') page = 1,
@Query('limit') limit = 20,
@Query('q') q?: string,
) {
return this.produkService.findAll({ page: +page, limit: +limit, q });
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() dto: CreateProdukDto, @CurrentUser() user: any) {
return this.produkService.create(dto, user.id);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.produkService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateProdukDto,
@CurrentUser() user: any,
) {
return this.produkService.update(id, dto, user.id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: any) {
return this.produkService.softDelete(id, user.id);
}
}
Python — Django & FastAPI
Ekosistem & Konteks
Python naik signifikan dalam tiga tahun terakhir didorong oleh gelombang AI/ML. Tim yang membangun fitur machine learning, natural language processing, atau integrasi model AI (OpenAI, Gemini, HuggingFace) secara alami memilih Python karena library AI terbaik (PyTorch, TensorFlow, scikit-learn, LangChain) hanya tersedia atau paling mature di Python.
Django adalah full-stack framework dengan filosofi “batteries included” — ORM, admin panel, autentikasi, form validation semuanya ada. FastAPI adalah framework modern yang menggunakan type hints Python untuk generate OpenAPI documentation otomatis dan validasi request, dengan performa mendekati Go untuk API yang async.
Kelebihan Django: Admin panel built-in yang sangat powerful untuk internal tool. ORM yang ekspresif. Django REST Framework (DRF) adalah salah satu library REST API terbaik yang pernah ada. Cocok untuk tim yang lebih familiar dengan data science.
Kelebihan FastAPI: Dokumentasi Swagger/ReDoc otomatis dari type hints. Performa tinggi berbasis ASGI dan async. Validasi dengan Pydantic sangat ketat dan ekspresif. Ideal untuk API yang diintegrasikan dengan model AI.
Kekurangan: GIL (Global Interpreter Lock) membatasi true parallelism untuk CPU-bound task. Deployment lebih kompleks dibanding PHP (butuh WSGI/ASGI server seperti Gunicorn/Uvicorn). Performa lebih rendah dari Go/Rust untuk throughput tinggi murni.
Cocok untuk: Aplikasi AI/ML, data analytics platform, startup yang tim-nya berlatar data science, internal tool perusahaan, prototipe cepat.
# FastAPI — Hello CRUD: Produk Router
# File: app/routers/produk.py
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.schemas.produk import ProdukCreate, ProdukUpdate, ProdukOut, ProdukListOut
from app.services import produk_service
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/produk", tags=["Produk"])
@router.get("/", response_model=ProdukListOut)
def index(
page: int = Query(1, ge=1),
limit: int = Query(20, le=100),
q: Optional[str] = None,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
return produk_service.list_produk(db, page=page, limit=limit, q=q)
@router.post("/", response_model=ProdukOut, status_code=status.HTTP_201_CREATED)
def store(
payload: ProdukCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
return produk_service.create_produk(db, payload, created_by=current_user.id)
@router.get("/{id}", response_model=ProdukOut)
def show(id: int, db: Session = Depends(get_db)):
produk = produk_service.get_produk(db, id)
if not produk:
raise HTTPException(status_code=404, detail="Produk tidak ditemukan")
return produk
@router.put("/{id}", response_model=ProdukOut)
def update(
id: int,
payload: ProdukUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
return produk_service.update_produk(db, id, payload, updated_by=current_user.id)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
def destroy(
id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
produk_service.soft_delete_produk(db, id, deleted_by=current_user.id)
Go — Gin & Fiber
Ekosistem & Konteks
Go (Golang) adalah bahasa yang dirancang Google untuk membangun sistem terdistribusi skala besar dengan kode yang mudah dibaca. Goroutine — unit konkurensi ringan Go — memungkinkan server Go menangani ratusan ribu koneksi simultan dengan memori yang sangat kecil dibanding Java atau Node.js. Gin adalah micro-framework yang sangat populer dengan performa mendekati raw HTTP handler. Fiber terinspirasi Express.js dan menggunakan fasthttp untuk performa lebih tinggi lagi.
Kelebihan: Performa sangat tinggi — Go secara konsisten mengungguli Python, PHP, dan bahkan Node.js dalam benchmark throughput. Kompilasi menghasilkan binary tunggal tanpa dependency runtime — deployment sangat sederhana. Goroutine dan channel membuat concurrent programming lebih aman daripada thread-based languages. Memory footprint sangat kecil. Static typing tanpa verbositas Java.
Kekurangan: Error handling berbasis return value (err != nil) terasa verbose dibanding exception-based languages. Tidak ada generics yang lengkap hingga Go 1.18 (generics baru ditambahkan). Ekosistem ORM kurang mature dibanding Java/PHP — GORM ada tapi tidak secanggih Hibernate atau Eloquent. Komunitas Indonesia lebih kecil — rekrutmen senior Go lebih sulit.
Cocok untuk: API gateway, microservices performa tinggi, background worker, CLI tools, sistem yang butuh throughput besar dengan resource server minimal.
// Go + Gin — Hello CRUD: Produk Handler
// File: internal/handler/produk_handler.go
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"app/internal/service"
"app/internal/dto"
)
type ProdukHandler struct {
svc service.ProdukService
}
func NewProdukHandler(svc service.ProdukService) *ProdukHandler {
return &ProdukHandler{svc: svc}
}
func (h *ProdukHandler) Index(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
q := c.Query("q")
result, err := h.svc.ListProduk(c.Request.Context(), page, limit, q)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func (h *ProdukHandler) Store(c *gin.Context) {
var req dto.CreateProdukRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": err.Error()})
return
}
userID := c.GetUint("user_id") // dari JWT middleware
produk, err := h.svc.CreateProduk(c.Request.Context(), req, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, produk)
}
func (h *ProdukHandler) Destroy(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID tidak valid"})
return
}
userID := c.GetUint("user_id")
if err := h.svc.SoftDeleteProduk(c.Request.Context(), uint(id), userID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produk tidak ditemukan"})
return
}
c.Status(http.StatusNoContent)
}
Rust — Actix-web & Axum
Ekosistem & Konteks
Rust adalah bahasa sistem yang memberikan performa setara C/C++ dengan keamanan memori yang dijamin pada waktu kompilasi tanpa garbage collector. Actix-web secara konsisten menempati posisi teratas benchmark web framework dunia. Axum dari tim Tokio lebih ergonomis dan lebih mudah dipelajari dengan tetap mempertahankan performa tinggi.
Kelebihan: Performa terbaik di antara semua pilihan — memory usage dan latency yang sangat rendah. Zero-cost abstractions. Memory safety tanpa GC. Sangat cocok untuk sistem yang harus berjalan di hardware terbatas (edge device, embedded server). Compiler Rust sangat ketat — bila kode dikompilasi, kemungkinan besar aman dari race condition dan null pointer.
Kekurangan: Learning curve tertinggi — konsep ownership, borrowing, dan lifetime butuh waktu berbulan-bulan untuk dikuasai. Waktu development jauh lebih lama dibanding bahasa lain. Ekosistem untuk web masih lebih kecil. Sangat tidak cocok untuk tim yang butuh iterasi cepat. Rekrutmen developer Rust di Indonesia sangat sulit.
Cocok untuk: Sistem kritis yang butuh performa dan keandalan sangat tinggi (payment gateway, sistem trading, IoT gateway), CLI tools performa tinggi, WASM (WebAssembly) untuk browser, platform yang berjalan di edge runtime seperti Cloudflare Workers.
C# — .NET / ASP.NET Core
Ekosistem & Konteks
Sejak .NET Core (kini .NET 5+) Microsoft membuat C# lintas platform dan open source. ASP.NET Core adalah salah satu framework web paling performant yang ada — lebih cepat dari Express.js dalam sebagian besar benchmark. Blazor memungkinkan komponen UI ditulis dalam C# yang dijalankan di browser via WebAssembly, menghilangkan kebutuhan JavaScript untuk tim yang lebih nyaman dengan C#.
Kelebihan: Ekosistem Microsoft yang sangat lengkap — Azure integration first-class, Active Directory, Office 365. Performa sangat baik untuk framework yang “opinionated”. Entity Framework Core adalah ORM yang mature. Lingkungan enterprise Windows yang sudah pakai SQL Server dan Visual Studio sangat natural dengan .NET.
Kekurangan: Identik kuat dengan ekosistem Microsoft — biaya lisensi bisa muncul di beberapa komponen. Developer C# Indonesia lebih sedikit dibanding PHP atau Java. Kurang relevan untuk startup yang ingin cloud-agnostic.
Cocok untuk: Perusahaan yang sudah all-in Microsoft stack (Azure, Office 365, SQL Server), sistem enterprise yang terintegrasi dengan Active Directory, ISV (Independent Software Vendor) yang target pasarnya korporat.
Ruby — Rails
Ekosistem & Konteks
Ruby on Rails adalah framework yang mempopulerkan konsep “Convention over Configuration” — bila Anda mengikuti konvensi Rails, hampir tidak ada kode boilerplate yang perlu ditulis. GitHub, Shopify, dan Airbnb dibangun di Rails dan dijalankan di skala massive. Rails sangat cocok untuk startup yang butuh eksplorasi cepat karena Rails generator dapat membuat scaffold CRUD lengkap (migration, model, controller, view, tests) dalam satu perintah.
Kelebihan: Development speed tertinggi setelah Laravel untuk tim yang menguasai Rails. Active Record ORM sangat ekspresif. Testing culture sangat kuat di komunitas Rails. Cocok untuk Lean Startup yang ingin validate idea secepat mungkin.
Kekurangan: Komunitas Indonesia sangat kecil — rekrutmen sulit, support lokal terbatas. Performa lebih rendah dari Go/Java. Konvensi Rails bisa menjadi beban bila produk berkembang jauh dari pola standar CRUD. Memory usage lebih tinggi dari kebanyakan alternatif.
Cocok untuk: Startup tahap ideasi yang butuh MVP dalam dua minggu, tim yang sudah punya pengalaman Rails, aplikasi yang pola domain-nya sangat CRUD-centric.
Kotlin — Ktor & Spring Boot
Ekosistem & Konteks
Kotlin adalah bahasa JVM modern yang dikembangkan JetBrains — lebih ringkas dari Java, null-safe secara default, dengan fitur coroutine untuk async programming yang lebih elegan dari Java threads. Kotlin Spring Boot menggabungkan ekosistem Spring yang mature dengan sintaks Kotlin yang lebih bersih. Ktor adalah framework async ringan yang lebih native Kotlin, cocok untuk microservices modern.
Kelebihan: Interoperabilitas 100% dengan Java — library Java apapun bisa digunakan langsung. Null safety built-in mengurangi NullPointerException yang jadi mimpi buruk developer Java. Coroutine Kotlin lebih ekspresif dari Java virtual threads. Sintaks jauh lebih ringkas dari Java — boilerplate berkurang 40-50%.
Kekurangan: Ekosistem masih lebih kecil dari Java. Kompilasi lebih lambat dari Java. Developer Kotlin senior di Indonesia sangat jarang — biasanya tim yang bisa Kotlin sudah bisa Java, dan migrasi membutuhkan waktu adaptasi. Ktor belum sepopuler Spring dalam ekosistem enterprise.
Cocok untuk: Tim Java yang ingin memodernisasi codebase, proyek baru yang butuh ekosistem JVM tapi ingin sintaks lebih bersih, aplikasi Android backend yang timnya sudah pakai Kotlin di mobile.
Tabel Perbandingan Ringkas
| Bahasa / Framework | Dev Speed | Performa | Ekosistem ID | Cocok Untuk |
|---|---|---|---|---|
| Java / Spring Boot | Lambat | Tinggi | Besar | Bank, BUMN, Enterprise |
| PHP / Laravel | Sangat cepat | Menengah | Terbesar | UMKM, Dinas, Startup kecil |
| PHP / WordPress | Tercepat | Rendah | Sangat besar | Website profil, Toko online sederhana |
| TS / NestJS | Cepat | Menengah-tinggi | Besar | Startup mid-range, Fullstack team |
| Python / Django | Cepat | Menengah | Menengah | Internal tool, Data team |
| Python / FastAPI | Cepat | Menengah-tinggi | Menengah | AI/ML service, API modern |
| Go / Gin | Menengah | Sangat tinggi | Kecil-menengah | Microservices, API gateway |
| Rust / Axum | Sangat lambat | Tertinggi | Sangat kecil | Sistem kritis, Edge runtime |
| C# / ASP.NET | Menengah | Tinggi | Kecil | Microsoft stack, Korporat |
| Ruby / Rails | Sangat cepat | Menengah | Sangat kecil | Startup ideasi, Prototipe cepat |
| Kotlin / Ktor | Cepat | Tinggi | Sangat kecil | Modern JVM, Tim ex-Android |
Tips. Untuk MVP pertama di Indonesia dengan tim 1–3 orang, Laravel atau NestJS adalah pilihan paling pragmatis: ekosistem besar, rekrutmen mudah, hosting murah, dan development speed tertinggi. Migrasi ke Go atau Rust bisa dilakukan setelah produk terbukti dan Anda tahu persis di mana bottleneck-nya.
Awas. Jangan pilih Rust atau Go hanya karena benchmark angkanya menarik. Untuk aplikasi dengan 100 pengguna serentak, Laravel yang dikonfigurasi dengan benar sudah lebih dari cukup. Optimasi prematur — memilih bahasa “cepat” sebelum ada beban nyata — adalah salah satu penyebab utama proyek MVP gagal karena tim kehabisan energi sebelum produk jadi.
REST API Implementation
REST API adalah kontrak antara backend dan semua klien yang mengonsumsinya — frontend web, aplikasi mobile, sistem integrasi pihak ketiga. Kontrak yang buruk membuat perubahan kecil di backend menjadi perombakan besar di sisi klien. Kontrak yang baik membuat backend dan frontend dapat berkembang secara independen, tim dapat bekerja paralel, dan versi API lama tetap berfungsi saat versi baru dirilis.
Struktur Endpoint CRUD Standar
Setiap modul dalam aplikasi — produk, pengguna, transaksi, laporan — mengikuti pola endpoint yang sama. Konsistensi ini adalah kunci agar tim frontend dapat memprediksi API tanpa membaca dokumentasi setiap saat, dan agar API gateway atau BFF layer dapat melakukan routing secara generik.
| Method | Path | Deskripsi | Request Body | Response |
|---|---|---|---|---|
| GET | /api/produk | List dengan pagination & filter | — | 200 + PaginatedList |
| POST | /api/produk | Buat data baru | ProdukPayload | 201 + ProdukObject |
| GET | /api/produk/:id | Detail satu data | — | 200 + ProdukObject |
| PUT | /api/produk/:id | Update keseluruhan | ProdukPayload | 200 + ProdukObject |
| PATCH | /api/produk/:id | Update sebagian field | Partial payload | 200 + ProdukObject |
| DELETE | /api/produk/:id | Soft delete | — | 204 No Content |
| POST | /api/produk/:id/restore | Restore dari soft delete | — | 200 + ProdukObject |
| DELETE | /api/produk/:id/force | Hard delete permanen | — | 204 No Content |
Request Validation
Validasi harus terjadi di layer request — sebelum data menyentuh service layer atau database. Validasi yang terlambat (di database constraint) memberikan pesan error yang tidak informatif dan tidak aman untuk ditampilkan ke pengguna. Validasi yang benar menghasilkan error dengan field name yang tepat, pesan dalam bahasa pengguna, dan kode HTTP yang benar (422 Unprocessable Entity untuk validation error, bukan 400 Bad Request yang terlalu generik).
// TypeScript + Zod — Request Validation Schema
// File: src/produk/schemas/produk.schema.ts
import { z } from 'zod';
export const createProdukSchema = z.object({
kode: z
.string()
.min(3, 'Kode minimal 3 karakter')
.max(20, 'Kode maksimal 20 karakter')
.regex(/^[A-Z0-9-]+$/, 'Kode hanya boleh huruf kapital, angka, dan tanda hubung'),
nama: z.string().min(1, 'Nama wajib diisi').max(255, 'Nama terlalu panjang'),
kategori_id: z.number().int().positive('Kategori tidak valid'),
harga: z.number().positive('Harga harus lebih dari 0').max(999_999_999, 'Harga terlalu besar'),
stok: z.number().int().min(0, 'Stok tidak boleh negatif').default(0),
satuan: z.enum(['pcs', 'kg', 'liter', 'dus', 'lusin'], {
errorMap: () => ({ message: 'Satuan tidak valid' }),
}),
deskripsi: z.string().max(2000).optional(),
aktif: z.boolean().default(true),
});
export type CreateProdukDto = z.infer<typeof createProdukSchema>;
// Middleware validasi
export function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
message: 'Data tidak valid',
errors: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
}
Response Transformation — Resource Layer
Jangan pernah mengembalikan raw database row langsung ke klien. Pola Resource (atau Transformer) memisahkan struktur database dari kontrak API — perubahan nama kolom di database tidak mempengaruhi respons API, field sensitif (password, token) tidak pernah bocor, dan computed fields (misalnya harga_diskon, status_label) bisa ditambahkan tanpa mengubah skema database.
// TypeScript — Response Resource/Transformer
// File: src/produk/produk.resource.ts
export interface ProdukResource {
id: number;
kode: string;
nama: string;
harga: number;
harga_formatted: string; // "Rp 150.000"
stok: number;
satuan: string;
stok_status: 'tersedia' | 'menipis' | 'habis';
kategori: { id: number; nama: string } | null;
aktif: boolean;
created_at: string;
updated_at: string;
}
export function toProdukResource(produk: any): ProdukResource {
return {
id: produk.id,
kode: produk.kode,
nama: produk.nama,
harga: produk.harga,
harga_formatted: new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(produk.harga),
stok: produk.stok,
satuan: produk.satuan,
stok_status:
produk.stok === 0
? 'habis'
: produk.stok <= produk.stok_minimum
? 'menipis'
: 'tersedia',
kategori: produk.kategori
? { id: produk.kategori.id, nama: produk.kategori.nama }
: null,
aktif: produk.aktif,
created_at: produk.created_at?.toISOString(),
updated_at: produk.updated_at?.toISOString(),
};
}
// Struktur respons list standar
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
last_page: number;
};
}
Global Error Handling
Error handling global mencegah stack trace dan pesan database mentah bocor ke klien produksi. Setiap jenis error mendapat kode HTTP yang tepat dan format respons yang konsisten. Logging dilakukan di layer ini — bukan di setiap handler — untuk mencegah duplikasi dan memastikan semua error tercatat.
Rumus. Format error standar:
{ "message": "...", "errors": {...}, "code": "PRODUK_NOT_FOUND" }. Fieldcodeadalah machine-readable identifier yang frontend gunakan untuk menentukan tindakan (tampilkan toast, redirect, retry). Jangan hanya bergantung pada HTTP status code karena ada kasus di mana 400 bisa berarti hal berbeda.
Logging & Monitoring
Setiap request masuk harus menghasilkan log entry yang memuat: timestamp, method, path, status code, durasi (ms), user ID bila terotentikasi, dan request ID unik. Request ID ini dipropagasi ke semua downstream service sehingga satu transaksi bisnis bisa ditelusuri lintas service menggunakan satu ID.
File Upload & Media Management
File upload adalah fitur yang terlihat sederhana dari sisi pengguna — pilih file, klik upload, selesai — namun menyimpan kompleksitas yang mengejutkan di sisi server. Tanpa arsitektur yang tepat, upload file menjadi sumber kebocoran storage, serangan malware, bottleneck performa, dan tagihan cloud yang membengkak. Setiap aplikasi yang menerima file dari pengguna harus menjawab empat pertanyaan: validasi apa yang dilakukan, di mana file disimpan, bagaimana file dioptimasi, dan bagaimana file dilayani ke pengguna akhir.
Jenis File dan Strategi Penanganan
Setiap jenis file membutuhkan pipeline yang berbeda. Gambar perlu resize dan kompresi agar tidak memboroskan bandwidth. Dokumen PDF perlu validasi integritas dan mungkin watermark. Video perlu transcoding ke format web-friendly (HLS/DASH). File yang diupload dari pengguna tidak diketahui asalnya — wajib diperlakukan sebagai tidak terpercaya sampai divalidasi.
| Jenis File | MIME Type | Max Size | Proses | Storage |
|---|---|---|---|---|
| Foto produk | image/jpeg, image/png, image/webp | 5 MB | Resize, compress, convert ke WebP | S3 / R2 |
| Avatar profil | image/jpeg, image/png | 2 MB | Crop 1:1, resize ke 256px, 128px, 64px | S3 / R2 |
| Dokumen PDF | application/pdf | 20 MB | Validasi, scan malware | S3 private |
| Spreadsheet | application/xlsx | 10 MB | Validasi format, parse preview | S3 private |
| Video | video/mp4, video/webm | 200 MB | Transcode via FFmpeg, generate thumbnail | S3 + CDN |
Upload Gambar dengan Node.js dan Multer
Multer adalah middleware Node.js untuk menangani multipart/form-data — format yang digunakan browser saat mengirim file. Konfigurasi yang benar mencakup: batas ukuran file, validasi MIME type (bukan hanya ekstensi file — ekstensi bisa dipalsukan), dan storage strategy. Untuk MVP, mulai dengan storage lokal, lalu migrasi ke S3 bila skala membesar.
// Node.js + Multer + Sharp — Image Upload Pipeline
// File: src/middleware/upload.middleware.ts
import multer from 'multer';
import sharp from 'sharp';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
// Simpan ke memory dulu — proses dulu sebelum ke storage final
const storage = multer.memoryStorage();
export const uploadImage = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (_req, file, cb) => {
if (ALLOWED_IMAGE_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Tipe file tidak diizinkan. Gunakan JPG, PNG, atau WebP.'));
}
},
});
const s3 = new S3Client({ region: process.env.AWS_REGION });
interface ImageVariant {
key: string;
width: number;
quality: number;
}
export async function processAndUploadImage(
buffer: Buffer,
folder: string,
): Promise<{ original: string; thumbnail: string; small: string }> {
const id = uuidv4();
const variants: ImageVariant[] = [
{ key: `${folder}/${id}/original.webp`, width: 1200, quality: 85 },
{ key: `${folder}/${id}/thumbnail.webp`, width: 400, quality: 80 },
{ key: `${folder}/${id}/small.webp`, width: 150, quality: 75 },
];
const results: Record<string, string> = {};
for (const variant of variants) {
const processed = await sharp(buffer)
.resize(variant.width, null, { withoutEnlargement: true, fit: 'inside' })
.webp({ quality: variant.quality })
.toBuffer();
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: variant.key,
Body: processed,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000', // 1 tahun — immutable karena UUID
}),
);
const variantName = path.basename(variant.key, '.webp');
results[variantName] = `${process.env.CDN_BASE_URL}/${variant.key}`;
}
return {
original: results['original'],
thumbnail: results['thumbnail'],
small: results['small'],
};
}
// Controller usage
// router.post('/produk/:id/foto', uploadImage.single('foto'), async (req, res) => {
// const urls = await processAndUploadImage(req.file.buffer, 'produk');
// await ProdukService.updateFoto(req.params.id, urls);
// res.json({ data: urls });
// });
Storage: Lokal vs S3 vs Cloudflare R2
Untuk MVP di Indonesia, tiga opsi storage yang paling relevan adalah: storage lokal di server VPS, Amazon S3, dan Cloudflare R2. Cloudflare R2 semakin populer karena tidak ada biaya egress (bandwidth keluar) — berbeda dengan S3 yang menagih biaya per GB bandwidth. Untuk aplikasi dengan banyak gambar yang diakses sering, R2 bisa menghemat puluhan juta rupiah per bulan dibanding S3.
Tips. Mulai dengan storage lokal di VPS selama tahap MVP — lebih murah dan tidak ada dependency eksternal. Migrasi ke R2 atau S3 setelah produk terbukti dan Anda perlu skalabilitas. Pastikan kode upload diabstraksi di balik interface (
StorageProvider) sehingga migrasi hanya perlu mengubah implementasi, bukan semua kode yang menggunakan storage.
CDN untuk Performa
CDN (Content Delivery Network) meletakkan salinan file di server yang tersebar geografis — pengguna di Surabaya mengunduh gambar dari node CDN di Surabaya, bukan dari server di Jakarta atau Amerika. Cloudflare adalah CDN yang paling terjangkau dan mudah dikonfigurasi untuk aplikasi Indonesia — versi gratisnya sudah mencakup kebutuhan sebagian besar MVP. AWS CloudFront adalah pilihan bila sudah menggunakan S3.
Validasi dan Virus Scanning
Jangan pernah langsung menyimpan file yang diupload tanpa validasi. Selain validasi MIME type di Multer, tambahkan pemeriksaan magic bytes — header biner di awal file yang tidak bisa dipalsukan seperti ekstensi. Untuk aplikasi enterprise atau yang menerima dokumen sensitif, integrasikan ClamAV (open source antivirus) atau layanan cloud scanning seperti VirusTotal API sebelum file dipindah ke storage permanen.
Search & Filtering
Fitur pencarian adalah fitur yang pertama digunakan pengguna saat koleksi data mulai bertumbuh — dan fitur yang paling sering diimplementasikan dengan cara yang salah. Pencarian naif menggunakan LIKE '%keyword%' tidak memanfaatkan index, lambat untuk tabel besar, dan tidak memahami relevansi. Membangun search yang baik berarti memilih teknik yang tepat berdasarkan volume data, kebutuhan relevansi, dan anggaran infrastruktur.
Full-Text Search dengan PostgreSQL tsvector
Untuk aplikasi dengan data hingga beberapa juta baris, PostgreSQL built-in full-text search sudah lebih dari cukup tanpa perlu Elasticsearch. Kolom tsvector menyimpan representasi kata-kata yang sudah dinormalisasi (stemmed), dan index GIN di atas kolom itu membuat pencarian sangat cepat — jauh lebih cepat dari LIKE biasa.
-- PostgreSQL — Full-Text Search Setup
-- Migration: tambah kolom dan index
ALTER TABLE produk ADD COLUMN IF NOT EXISTS search_vector tsvector;
-- Populate awal
UPDATE produk
SET search_vector = to_tsvector('indonesian',
coalesce(kode, '') || ' ' ||
coalesce(nama, '') || ' ' ||
coalesce(deskripsi, '')
)
WHERE deleted_at IS NULL;
-- Index GIN untuk performa
CREATE INDEX idx_produk_search_vector ON produk USING gin(search_vector);
-- Trigger untuk auto-update saat data berubah
CREATE OR REPLACE FUNCTION produk_search_vector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('indonesian',
coalesce(NEW.kode, '') || ' ' ||
coalesce(NEW.nama, '') || ' ' ||
coalesce(NEW.deskripsi, '')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER produk_search_vector_trigger
BEFORE INSERT OR UPDATE ON produk
FOR EACH ROW EXECUTE FUNCTION produk_search_vector_update();
-- Query pencarian dengan ranking relevansi
SELECT
id,
kode,
nama,
harga,
ts_rank(search_vector, query) AS rank
FROM produk,
to_tsquery('indonesian', 'sepatu & kulit') AS query
WHERE search_vector @@ query
AND deleted_at IS NULL
ORDER BY rank DESC
LIMIT 20 OFFSET 0;
Advanced Filtering Multi-Kriteria
Filtering di aplikasi bisnis jarang sesederhana satu keyword. Pengguna ingin memfilter produk berdasarkan kategori, rentang harga, stok tersedia, status aktif, dan tanggal dibuat sekaligus. Membangun query builder yang aman dari SQL injection dan efisien adalah keterampilan kritis yang sering diabaikan.
// TypeScript — Dynamic Query Builder (Prisma)
// File: src/produk/produk.repository.ts
interface ProdukFilter {
q?: string;
kategori_id?: number;
harga_min?: number;
harga_max?: number;
stok_min?: number;
aktif?: boolean;
created_after?: Date;
created_before?: Date;
sort_by?: 'nama' | 'harga' | 'stok' | 'created_at';
sort_dir?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export async function findProduk(filter: ProdukFilter) {
const {
q,
kategori_id,
harga_min,
harga_max,
stok_min,
aktif,
created_after,
created_before,
sort_by = 'created_at',
sort_dir = 'desc',
page = 1,
limit = 20,
} = filter;
const where: any = {
deleted_at: null, // WAJIB: filter soft-deleted
};
// Full-text search
if (q) {
where.OR = [
{ nama: { contains: q, mode: 'insensitive' } },
{ kode: { contains: q, mode: 'insensitive' } },
{ deskripsi: { contains: q, mode: 'insensitive' } },
];
}
if (kategori_id) where.kategori_id = kategori_id;
if (aktif !== undefined) where.aktif = aktif;
if (stok_min !== undefined) where.stok = { gte: stok_min };
if (harga_min !== undefined || harga_max !== undefined) {
where.harga = {};
if (harga_min !== undefined) where.harga.gte = harga_min;
if (harga_max !== undefined) where.harga.lte = harga_max;
}
if (created_after || created_before) {
where.created_at = {};
if (created_after) where.created_at.gte = created_after;
if (created_before) where.created_at.lte = created_before;
}
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
prisma.produk.findMany({
where,
orderBy: { [sort_by]: sort_dir },
skip,
take: limit,
include: { kategori: { select: { id: true, nama: true } } },
}),
prisma.produk.count({ where }),
]);
return {
data: data.map(toProdukResource),
meta: {
total,
page,
limit,
last_page: Math.ceil(total / limit),
},
};
}
Autocomplete & Suggestion
Autocomplete yang responsif adalah fitur yang meningkatkan pengalaman pengguna secara dramatis, terutama di form pencarian. Implementasi yang benar membutuhkan: debounce di sisi klien (jangan kirim request setiap keystroke), endpoint yang sangat cepat (target <50ms), dan cache di Redis untuk query yang sering dilakukan.
-- PostgreSQL — Query Autocomplete dengan prefix search
-- Lebih cepat dari LIKE karena menggunakan index B-tree kiri
SELECT DISTINCT ON (nama) id, nama, kode
FROM produk
WHERE
nama ILIKE 'sepa%' -- prefix match, bisa pakai index
AND deleted_at IS NULL
AND aktif = true
ORDER BY nama, id
LIMIT 10;
-- Atau dengan trigram index untuk fuzzy matching
-- Aktifkan extension: CREATE EXTENSION pg_trgm;
-- CREATE INDEX idx_produk_nama_trgm ON produk USING gin(nama gin_trgm_ops);
SELECT id, nama, kode,
similarity(nama, 'spatu') AS sim -- typo tolerance
FROM produk
WHERE nama % 'spatu' -- trigram similarity threshold
AND deleted_at IS NULL
ORDER BY sim DESC
LIMIT 10;
Elasticsearch untuk Skala Besar
Elasticsearch relevan saat data sudah di atas 10 juta record, ketika pencarian multi-bahasa dan sinonim dibutuhkan, atau ketika analitik pencarian (query logs, click-through rate) diperlukan. Biaya operasional Elasticsearch signifikan — setidaknya membutuhkan 3 node untuk produksi yang andal. Untuk MVP, PostgreSQL full-text search sudah lebih dari cukup dan bisa melayani aplikasi dengan jutaan baris tanpa masalah performa bila index dikonfigurasi dengan benar.
Rumus. Pilih search engine berdasarkan volume: <1 juta record → PostgreSQL LIKE + index; 1–50 juta record → PostgreSQL tsvector + GIN index; >50 juta record atau butuh relevancy scoring canggih → Elasticsearch/OpenSearch. Jangan over-engineer di fase awal.
Relevancy Ranking
Tidak semua hasil pencarian sama pentingnya. Produk yang namanya persis sama dengan keyword lebih relevan dari produk yang hanya menyebut keyword di deskripsi. PostgreSQL ts_rank dan ts_rank_cd memberikan scoring sederhana. Untuk ranking yang lebih canggih (boosting berdasarkan popularitas, tanggal, atau kriteria bisnis), Elasticsearch BM25 scoring dengan custom boost lebih tepat.
Reporting & Export
Laporan adalah fitur yang sering diremehkan saat membangun MVP — dimasukkan di sprint terakhir, dibuat dengan tergesa, dan akhirnya menjadi sumber keluhan paling besar dari pengguna enterprise dan pemerintahan. Kenyataannya, untuk segmen dinas pemerintah, koperasi, dan UMKM yang sudah terbiasa dengan Excel dan laporan bulanan berjenjang, kemampuan export dan cetak laporan sama pentingnya dengan fitur CRUD itu sendiri.
Arsitektur Reporting yang Benar
Laporan kompleks tidak boleh dijalankan di request-response cycle yang sama dengan user request biasa. Query laporan yang menarik data jutaan baris bisa memakan waktu 30–60 detik — jauh di atas timeout HTTP standar (30 detik) dan akan membuat pengguna menganggap aplikasi “hang”. Pola yang benar adalah: pengguna meminta laporan → sistem membuat job di queue → job diproses di background worker → selesai → pengguna mendapat notifikasi dan link download.
PDF Generation dengan Puppeteer
Puppeteer adalah library Node.js yang mengontrol Chrome headless — browser nyata yang me-render HTML/CSS menjadi PDF dengan akurasi pixel-perfect. Kelebihan pendekatan ini: laporan dapat di-styling dengan CSS yang sama dengan tampilan web, tabel otomatis berpindah halaman, dan gambar/grafik SVG dirender dengan benar. Alternatif yang lebih ringan adalah PDFKit (generate PDF secara programatik) atau wkhtmltopdf (binary standalone berbasis WebKit).
// Node.js + Puppeteer — PDF Report Generator
// File: src/jobs/generate-report.job.ts
import puppeteer from 'puppeteer';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { renderReportHtml } from '../templates/report.template';
interface ReportJob {
type: 'laporan-penjualan' | 'laporan-stok' | 'laporan-pelanggan';
params: {
bulan: number;
tahun: number;
toko_id: number;
};
user_id: number;
job_id: string;
}
export async function generatePdfReport(job: ReportJob): Promise<string> {
// 1. Ambil data dari database
const data = await fetchReportData(job.type, job.params);
// 2. Render HTML laporan
const html = await renderReportHtml(job.type, {
...data,
generated_at: new Date().toLocaleDateString('id-ID', {
day: 'numeric', month: 'long', year: 'numeric',
}),
});
// 3. Launch Puppeteer dan generate PDF
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
displayHeaderFooter: true,
headerTemplate: `<div style="font-size:9px;width:100%;text-align:center;color:#666">LAPORAN RAHASIA — HANYA UNTUK INTERNAL</div>`,
footerTemplate: `<div style="font-size:9px;width:100%;text-align:center;color:#666">Halaman <span class="pageNumber"></span> dari <span class="totalPages"></span></div>`,
});
// 4. Upload ke S3
const s3Key = `reports/${job.job_id}/laporan-${Date.now()}.pdf`;
const s3 = new S3Client({ region: process.env.AWS_REGION });
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: s3Key,
Body: pdf,
ContentType: 'application/pdf',
// File expired otomatis setelah 24 jam
Expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
}));
return s3Key;
} finally {
await browser.close();
}
}
Excel Export dengan ExcelJS
Untuk data tabular yang perlu diolah lebih lanjut oleh pengguna, Excel (XLSX) lebih tepat dari PDF. ExcelJS memungkinkan pembuatan spreadsheet dengan styling, multiple sheet, formula, dan validasi data. Format yang dihasilkan kompatibel dengan Microsoft Excel, LibreOffice, dan Google Sheets.
// Node.js + ExcelJS — Excel Export
// File: src/exports/produk.export.ts
import ExcelJS from 'exceljs';
export async function exportProdukToExcel(produkList: any[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Sistem Aplikasi';
workbook.created = new Date();
const sheet = workbook.addWorksheet('Data Produk', {
views: [{ state: 'frozen', ySplit: 1 }], // freeze header row
});
// Header dengan styling
sheet.columns = [
{ header: 'No', key: 'no', width: 6 },
{ header: 'Kode', key: 'kode', width: 15 },
{ header: 'Nama Produk', key: 'nama', width: 35 },
{ header: 'Kategori', key: 'kategori', width: 20 },
{ header: 'Harga (Rp)', key: 'harga', width: 16 },
{ header: 'Stok', key: 'stok', width: 10 },
{ header: 'Satuan', key: 'satuan', width: 10 },
{ header: 'Status', key: 'status', width: 12 },
];
// Style header
const headerRow = sheet.getRow(1);
headerRow.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E3A8A' } };
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 };
cell.alignment = { vertical: 'middle', horizontal: 'center' };
cell.border = {
bottom: { style: 'medium', color: { argb: 'FFF59E0B' } },
};
});
headerRow.height = 30;
// Isi data
produkList.forEach((produk, index) => {
const row = sheet.addRow({
no: index + 1,
kode: produk.kode,
nama: produk.nama,
kategori: produk.kategori?.nama ?? '-',
harga: produk.harga,
stok: produk.stok,
satuan: produk.satuan,
status: produk.aktif ? 'Aktif' : 'Nonaktif',
});
// Format angka untuk kolom harga
row.getCell('harga').numFmt = '#,##0';
row.getCell('stok').numFmt = '#,##0';
// Warna baris bergantian
if (index % 2 === 0) {
row.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FB' } };
});
}
});
// Auto-filter
sheet.autoFilter = { from: 'A1', to: 'H1' };
return workbook.xlsx.writeBuffer() as Promise<Buffer>;
}
CSV Export untuk Data Sederhana
Untuk data tabular tanpa kebutuhan formatting khusus, CSV adalah format paling portabel. Hampir semua sistem bisa membaca CSV. Gunakan library seperti csv-stringify di Node.js atau League\Csv di PHP untuk menghindari masalah encoding dan karakter khusus (koma dalam value, newline, tanda kutip).
Tips. Selalu sertakan BOM (Byte Order Mark)
di awal file CSV bila target pengguna menggunakan Excel di Windows — tanpa BOM, Excel sering salah membaca encoding UTF-8 dan karakter Indonesia (é, ñ, dll) muncul sebagai karakter aneh. Ini bug lama Excel yang masih ada hingga hari ini.
Scheduled Report
Laporan otomatis yang dikirim via email atau WhatsApp setiap hari/minggu/bulan adalah fitur yang sangat diapresiasi pengguna bisnis. Implementasikan dengan cron job: setiap hari pukul 07.00 WIB, sistem generate laporan harian dan kirim ke email manajer. Gunakan BullMQ di Node.js, Horizon di Laravel, atau Celery Beat di Python untuk scheduling yang andal dengan retry otomatis bila gagal.
Notification System
Notifikasi adalah jembatan antara sistem dan pengguna — mekanisme yang membuat aplikasi terasa hidup dan proaktif, bukan pasif. Di Indonesia, notifikasi melalui WhatsApp memiliki tingkat buka yang jauh lebih tinggi dibanding email — pengguna UMKM dan dinas lokal hampir pasti membuka WhatsApp setiap jam, tapi mungkin mengecek email hanya seminggu sekali. Memilih channel notifikasi yang tepat bukan keputusan teknis, melainkan keputusan tentang perilaku pengguna target Anda.
Arsitektur Multi-Channel Notification
Sistem notifikasi yang baik memisahkan apa yang dikirim (event) dari bagaimana dikirim (channel). Ketika sebuah event terjadi — misalnya pesanan baru masuk — sistem tidak langsung memanggil API WhatsApp atau SendGrid. Sebaliknya, event dipublikasikan ke queue, dan worker yang berlangganan queue tersebut menentukan channel mana yang digunakan berdasarkan preferensi pengguna dan konfigurasi notifikasi.
| Channel | Tingkat Buka (ID) | Biaya | Cocok Untuk | Provider |
|---|---|---|---|---|
| WhatsApp Business | 95%+ | Rp200–700/pesan | Transaksional, reminder OTP | Fonnte, WA Cloud API |
| 15–25% | Rp0 – Rp5/email | Laporan, invoice, marketing | SendGrid, SES, Mailgun | |
| Push Notification | 40–70% | Gratis (Firebase) | Mobile app, real-time alert | Firebase FCM |
| In-App | 100% (saat online) | Gratis (biaya infra) | Semua notifikasi sistem | Self-hosted |
| SMS | 98%+ | Rp300–600/SMS | OTP, verifikasi kritis | Twilio, Zenziva |
Email dengan SendGrid / Amazon SES
Email transaksional — konfirmasi registrasi, reset password, invoice, laporan — adalah kebutuhan dasar hampir setiap aplikasi. Jangan pernah mengirim email transaksional dari SMTP server sendiri di VPS — reputasi IP VPS baru hampir pasti buruk dan email akan masuk spam. Gunakan layanan transactional email yang sudah memiliki reputasi IP baik: SendGrid (free tier 100 email/hari), Amazon SES (biaya sangat murah, sekitar $0.10 per 1000 email), atau Mailgun.
// Node.js — Multi-Channel Notification Service
// File: src/notifications/notification.service.ts
import sgMail from '@sendgrid/mail';
import admin from 'firebase-admin';
import { Queue } from 'bullmq';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export interface NotificationPayload {
type: 'pesanan_baru' | 'stok_menipis' | 'laporan_siap' | 'otp';
user_id: number;
data: Record<string, any>;
channels: ('email' | 'whatsapp' | 'push' | 'in_app')[];
}
const notifQueue = new Queue('notifications', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
});
// Tambahkan notifikasi ke queue (non-blocking)
export async function sendNotification(payload: NotificationPayload): Promise<void> {
await notifQueue.add('send', payload, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
});
}
// Worker memproses queue
export async function processNotification(payload: NotificationPayload): Promise<void> {
const user = await getUserById(payload.user_id);
const template = getTemplate(payload.type, payload.data);
const promises: Promise<void>[] = [];
if (payload.channels.includes('email') && user.email) {
promises.push(sendEmail({
to: user.email,
subject: template.email_subject,
html: template.email_html,
}));
}
if (payload.channels.includes('push') && user.fcm_token) {
promises.push(sendPushNotification({
token: user.fcm_token,
title: template.push_title,
body: template.push_body,
data: { type: payload.type, ...payload.data },
}));
}
if (payload.channels.includes('in_app')) {
promises.push(saveInAppNotification({
user_id: payload.user_id,
title: template.push_title,
body: template.push_body,
type: payload.type,
data: payload.data,
}));
}
await Promise.allSettled(promises); // jangan biarkan satu gagal batalkan yang lain
}
async function sendEmail(options: { to: string; subject: string; html: string }): Promise<void> {
await sgMail.send({
from: { email: process.env.EMAIL_FROM!, name: process.env.APP_NAME! },
to: options.to,
subject: options.subject,
html: options.html,
});
}
async function sendPushNotification(options: {
token: string;
title: string;
body: string;
data: Record<string, string>;
}): Promise<void> {
await admin.messaging().send({
token: options.token,
notification: { title: options.title, body: options.body },
data: options.data,
android: { priority: 'high' },
apns: { payload: { aps: { sound: 'default' } } },
});
}
WhatsApp Business API
WhatsApp Business API adalah cara resmi mengirim pesan WhatsApp secara programatik. Ada dua jalur: Meta Cloud API (resmi, gratis 1000 percakapan/bulan) dan penyedia third-party seperti Fonnte atau Wablas yang menggunakan nomor WhatsApp biasa — lebih murah tapi tidak didukung secara resmi oleh Meta dan berisiko diblokir.
Untuk aplikasi produksi yang mengirim pesan ke ribuan pengguna, gunakan Meta Cloud API melalui WhatsApp Business Platform. Template pesan harus disetujui Meta terlebih dahulu untuk pesan yang dikirim di luar window 24 jam. Biaya sekitar $0.004–$0.009 per percakapan (window 24 jam) tergantung kategori pesan dan negara penerima.
// WhatsApp Business Cloud API — Kirim Template Message
// File: src/notifications/whatsapp.service.ts
export async function sendWhatsAppTemplate(
phoneNumber: string, // format internasional: "628123456789"
templateName: string,
templateParams: string[],
): Promise<void> {
const url = `https://graph.facebook.com/v18.0/${process.env.WA_PHONE_ID}/messages`;
const payload = {
messaging_product: 'whatsapp',
to: phoneNumber,
type: 'template',
template: {
name: templateName,
language: { code: 'id' },
components: [
{
type: 'body',
parameters: templateParams.map((text) => ({ type: 'text', text })),
},
],
},
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WA_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`WhatsApp API error: ${JSON.stringify(error)}`);
}
}
// Contoh penggunaan
// await sendWhatsAppTemplate(
// '628123456789',
// 'pesanan_terkonfirmasi',
// ['Budi Santoso', 'ORD-2024-001', 'Rp 150.000', 'Toko Maju Jaya'],
// );
In-App Notification & Real-Time
Notifikasi in-app adalah lonceng kecil di pojok kanan atas aplikasi yang menampilkan jumlah notifikasi belum dibaca. Implementasinya membutuhkan tabel notifications di database dan mekanisme real-time untuk update badge tanpa reload halaman. Dua pilihan real-time yang umum: WebSocket (koneksi persisten via Socket.io atau native WebSocket) dan Server-Sent Events (SSE, lebih sederhana, unidirectional dari server ke klien).
-- Tabel in-app notifications
-- File: migrations/create_notifications_table.sql
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- NO FOREIGN KEY — index manual
type VARCHAR(100) NOT NULL, -- 'pesanan_baru', 'stok_menipis', dll
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
data JSONB DEFAULT '{}', -- payload tambahan untuk deep link
read_at TIMESTAMPTZ, -- NULL = belum dibaca
created_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- SOFT DELETE wajib
-- NO FOREIGN KEY sesuai rumus wajib
-- Integritas dijaga di application layer
);
-- Index wajib untuk performa query per-user
CREATE INDEX idx_notifications_user_id ON notifications(user_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_notifications_user_unread ON notifications(user_id, created_at DESC)
WHERE read_at IS NULL AND deleted_at IS NULL;
Template Engine untuk Notifikasi
Pesan notifikasi tidak boleh di-hardcode di kode. Template engine memungkinkan teks pesan diubah tanpa deploy ulang, mendukung multi-bahasa, dan memudahkan personalisasi variabel (nama pengguna, nomor order, jumlah). Untuk template email HTML, Handlebars atau MJML (framework HTML email yang kompatibel dengan semua email client) adalah pilihan terbaik.
Awas. Jangan simpan API key WhatsApp, SendGrid, atau Firebase di kode. Gunakan environment variable dan pastikan
.envmasuk ke.gitignore. Kebocoran API key notifikasi berarti pihak ketiga bisa mengirim pesan atas nama aplikasi Anda — ini insiden keamanan serius yang bisa merusak kepercayaan pengguna secara permanen.
Rumus. Notifikasi yang efektif di Indonesia: (1) WhatsApp untuk pesan transaksional penting (OTP, konfirmasi pesanan), (2) Email untuk laporan dan dokumen, (3) Push notification untuk alert real-time di mobile, (4) In-app untuk semua yang lain. Tawari pengguna kontrol atas preferensi notifikasi — pengguna yang bisa mengatur channel dan frekuensi sendiri jarang mematikan notifikasi sama sekali.
Frontend Implementasi
React/Next.js (Enterprise)
Dunia frontend bergerak cepat, tetapi selama lima tahun terakhir satu kombinasi teknologi konsisten mendominasi pilihan tim engineering di seluruh dunia: React sebagai library UI, dan Next.js sebagai framework produksinya. Ketika Anda membangun aplikasi manajemen internal—sistem inventori untuk distributor di Surabaya, platform monitoring keuangan untuk koperasi simpan pinjam, atau dashboard operasional untuk dinas pemerintah—pilihan ini bukan sekadar ikut tren. React dan Next.js memberikan struktur yang cukup untuk aplikasi skala besar sekaligus tidak memaksa Anda menggunakan satu pola yang kaku.
Setup Next.js App Router
Next.js versi 13 ke atas memperkenalkan App Router yang mengubah cara kita menyusun halaman dan komponen. Folder app/ menggantikan pages/ sebagai konvensi utama. Setiap folder di dalam app/ dapat berisi file page.tsx yang secara otomatis menjadi rute publik, layout.tsx yang berlaku sebagai wrapper persisten, dan loading.tsx untuk skeleton state saat data sedang diambil.
Untuk memulai proyek MVP baru dari nol, jalankan perintah berikut dan pilih opsi yang direkomendasikan:
# Buat proyek baru (TypeScript + Tailwind + App Router)
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
# Masuk ke direktori proyek
cd my-app
# Install dependensi yang hampir selalu dibutuhkan
npm install \
zustand \
@tanstack/react-query \
react-hook-form \
zod \
@hookform/resolvers \
axios \
next-auth \
clsx \
tailwind-merge
# Dev dependencies
npm install -D \
@types/node \
prettier \
prettier-plugin-tailwindcss \
vitest \
@testing-library/react \
@testing-library/user-event \
@vitejs/plugin-react \
jsdom
Struktur folder yang direkomendasikan untuk aplikasi enterprise mengikuti prinsip feature-first—setiap fitur besar memiliki foldernya sendiri, bukan diorganisir berdasarkan jenis file (komponen, hook, util):
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Route group — halaman login/register
│ │ ├── login/page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/ # Route group — halaman yang butuh login
│ │ ├── layout.tsx # Layout dengan sidebar
│ │ ├── produk/
│ │ │ ├── page.tsx # Daftar produk (CRUD table)
│ │ │ └── [id]/page.tsx # Detail produk
│ │ ├── laporan/page.tsx
│ │ └── pengaturan/page.tsx
│ ├── api/ # API Routes (jika backend di Next.js juga)
│ │ └── auth/[...nextauth]/route.ts
│ └── layout.tsx # Root layout (font, provider global)
├── components/
│ ├── ui/ # Komponen atomic (Button, Input, Modal)
│ └── shared/ # Komponen lintas fitur (Header, Breadcrumb)
├── features/
│ ├── produk/ # Semua file terkait fitur Produk
│ │ ├── components/ # Table, Form, DetailPanel
│ │ ├── hooks/ # useProduk, useProdukMutation
│ │ ├── store/ # Zustand slice produk
│ │ └── types.ts
│ └── auth/
├── lib/
│ ├── api.ts # Axios instance + interceptor
│ ├── auth.ts # NextAuth config
│ └── utils.ts # cn(), formatRupiah(), dll
└── stores/
└── useUIStore.ts # Global UI state (sidebar, modal)
Server Components vs Client Components
Kesalahan paling umum developer yang baru pindah ke App Router adalah menaruh "use client" di semua komponen secara refleks. Padahal, React Server Components (RSC) adalah keunggulan terbesar Next.js 13+. RSC dirender di server, tidak mengandung JavaScript di browser, dan dapat mengakses database atau filesystem langsung tanpa melewati API.
Rumus. Gunakan Server Component secara default. Tambahkan
"use client"hanya ketika komponen membutuhkan:useState,useEffect, event handler browser (onClick,onChange), atau Web API (localStorage,window).
Contoh konkret: halaman daftar produk yang mengambil data dari API dapat sepenuhnya menjadi Server Component. Hanya bagian tabel yang memiliki sorting interaktif yang perlu menjadi Client Component. Pola ini disebut component boundary splitting—pisahkan logika interaktif ke komponen kecil dan beri "use client" hanya pada komponen tersebut.
State Management: Zustand, Redux, dan Context
Untuk MVP dan aplikasi skala sedang, Zustand adalah pilihan terbaik. API-nya minimal, tidak membutuhkan boilerplate, dan mendukung TypeScript dengan baik. Redux Toolkit direkomendasikan hanya jika tim Anda sudah terbiasa dengan Redux atau jika aplikasi memerlukan time-travel debugging dan middleware yang kompleks.
Data Fetching dengan React Query
React Query (TanStack Query) menangani semua kompleksitas server state: loading state, error state, caching, refetching saat window kembali fokus, dan pagination. Gunakan hook useQuery untuk READ dan useMutation untuk CREATE/UPDATE/DELETE.
Form Handling: React Hook Form + Zod
React Hook Form menggunakan uncontrolled components secara default, yang berarti tidak ada re-render setiap kali pengguna mengetik. Kombinasikan dengan Zod untuk validasi schema yang type-safe dari frontend hingga backend:
// src/features/produk/components/ProdukForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
// Schema Zod — satu sumber kebenaran untuk validasi
const ProdukSchema = z.object({
nama: z.string().min(3, "Nama minimal 3 karakter").max(100),
sku: z.string().min(1, "SKU wajib diisi").regex(/^[A-Z0-9-]+$/, "SKU hanya huruf kapital, angka, dan tanda hubung"),
harga: z.number({ invalid_type_error: "Harga harus berupa angka" }).min(0, "Harga tidak boleh negatif"),
stok: z.number().int("Stok harus bilangan bulat").min(0),
kategori_id: z.string().uuid("Pilih kategori yang valid"),
deskripsi: z.string().optional(),
});
type ProdukFormData = z.infer<typeof ProdukSchema>;
interface ProdukFormProps {
produkId?: string; // Ada = mode Edit, tidak ada = mode Tambah
defaultValues?: Partial<ProdukFormData>;
onSuccess: () => void;
}
export function ProdukForm({ produkId, defaultValues, onSuccess }: ProdukFormProps) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<ProdukFormData>({
resolver: zodResolver(ProdukSchema),
defaultValues,
});
const mutation = useMutation({
mutationFn: (data: ProdukFormData) =>
produkId
? api.put(`/produk/${produkId}`, data)
: api.post("/produk", data),
onSuccess: () => {
// Invalidasi cache agar tabel otomatis refresh
queryClient.invalidateQueries({ queryKey: ["produk"] });
reset();
onSuccess();
},
});
return (
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nama Produk <span className="text-red-500">*</span>
</label>
<input
{...register("nama")}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Contoh: Beras Premium 5kg"
/>
{errors.nama && (
<p className="mt-1 text-xs text-red-600">{errors.nama.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SKU</label>
<input {...register("sku")} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
{errors.sku && <p className="mt-1 text-xs text-red-600">{errors.sku.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Harga (Rp)</label>
<input
type="number"
{...register("harga", { valueAsNumber: true })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
/>
{errors.harga && <p className="mt-1 text-xs text-red-600">{errors.harga.message}</p>}
</div>
</div>
<button
type="submit"
disabled={isSubmitting || mutation.isPending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm font-medium"
>
{isSubmitting || mutation.isPending ? "Menyimpan..." : produkId ? "Perbarui Produk" : "Tambah Produk"}
</button>
</form>
);
}
Authentication Flow dengan NextAuth
NextAuth v5 (Auth.js) menyederhanakan alur autentikasi JWT-based. Konfigurasinya terpusat di satu file, dan middleware Next.js memproteksi rute secara otomatis tanpa perlu membungkus setiap halaman dengan komponen HOC.
Tips. Simpan data sesi pengguna (id, nama, role, foto) di JWT token. Jangan menyimpan data sensitif seperti password hash di sana. Untuk data yang sering berubah (misalnya jumlah notifikasi), ambil via API terpisah, bukan dari token.
Layout System: Sidebar + Content
Layout adalah kerangka yang menentukan bagaimana pengguna bernavigasi dan merasakan produk Anda secara keseluruhan. Untuk aplikasi manajemen internal—yang digunakan puluhan hingga ratusan kali sehari oleh operator dan manajer—layout yang buruk akan menciptakan frustrasi akumulatif yang tidak terlihat dalam demo tetapi sangat terasa dalam pemakaian harian. Sidebar kiri dengan ikon dan teks, header yang konsisten, dan breadcrumb yang informatif bukan sekadar elemen estetika; mereka adalah infrastruktur navigasi yang menentukan produktivitas pengguna.
Arsitektur Layout Admin
Layout aplikasi admin terdiri dari tiga lapisan yang bekerja secara hierarkis. Lapisan pertama adalah root layout di app/layout.tsx yang mendefinisikan font, warna dasar, dan provider global (React Query, Zustand, tema). Lapisan kedua adalah dashboard layout di app/(dashboard)/layout.tsx yang mengandung sidebar dan header. Lapisan ketiga adalah komponen konten halaman individual yang mengisi area utama.
Implementasi Sidebar Responsif
Sidebar yang baik memiliki tiga mode: expanded (220px, ikon + teks) di desktop besar, collapsed (64px, hanya ikon dengan tooltip) di desktop kecil, dan drawer overlay di mobile. State sidebar disimpan di Zustand agar persisten selama sesi dan dapat diakses dari komponen mana pun.
// src/components/shared/Sidebar.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import {
LayoutDashboard, Package, FileText, BarChart2,
Settings, ChevronDown, ChevronRight, X, Menu
} from "lucide-react";
import { useUIStore } from "@/stores/useUIStore";
interface NavItem {
label: string;
href?: string;
icon: React.ReactNode;
children?: NavItem[];
}
const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard size={18} /> },
{
label: "Produk",
icon: <Package size={18} />,
children: [
{ label: "Daftar Produk", href: "/produk", icon: <Package size={16} /> },
{ label: "Kategori", href: "/produk/kategori", icon: <Package size={16} /> },
{ label: "Stok", href: "/produk/stok", icon: <Package size={16} /> },
],
},
{ label: "Laporan", href: "/laporan", icon: <FileText size={18} /> },
{ label: "Analitik", href: "/analitik", icon: <BarChart2 size={18} /> },
{ label: "Pengaturan", href: "/pengaturan", icon: <Settings size={18} /> },
];
function NavItemComponent({ item, collapsed }: { item: NavItem; collapsed: boolean }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const hasChildren = !!item.children?.length;
const isActive = item.href ? pathname.startsWith(item.href) : false;
const isGroupActive = item.children?.some(c => c.href && pathname.startsWith(c.href));
if (hasChildren) {
return (
<div>
<button
onClick={() => setOpen(!open)}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
isGroupActive
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
)}
>
<span className="shrink-0">{item.icon}</span>
{!collapsed && (
<>
<span className="flex-1 text-left">{item.label}</span>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</>
)}
</button>
{open && !collapsed && (
<div className="ml-6 mt-1 space-y-1 border-l border-white/20 pl-3">
{item.children!.map(child => (
<Link
key={child.href}
href={child.href!}
className={clsx(
"block px-2 py-2 rounded-md text-xs transition-colors",
pathname === child.href
? "bg-amber-400/20 text-amber-300 font-medium"
: "text-white/60 hover:text-white hover:bg-white/10"
)}
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
}
return (
<Link
href={item.href!}
className={clsx(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
isActive
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
)}
>
<span className="shrink-0">{item.icon}</span>
{!collapsed && <span>{item.label}</span>}
</Link>
);
}
export function Sidebar() {
const { sidebarCollapsed, setSidebarCollapsed, mobileSidebarOpen, setMobileSidebarOpen } = useUIStore();
return (
<>
{/* Overlay untuk mobile */}
{mobileSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setMobileSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={clsx(
"fixed top-0 left-0 h-full bg-[#1e3a8a] z-50 flex flex-col transition-all duration-300",
sidebarCollapsed ? "w-16" : "w-56",
mobileSidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
)}
>
{/* Logo */}
<div className="h-14 flex items-center px-3 bg-[#0c1b3d] shrink-0">
<div className="w-8 h-8 rounded-md bg-amber-400 flex items-center justify-center shrink-0">
<span className="text-blue-900 font-bold text-sm">A</span>
</div>
{!sidebarCollapsed && (
<div className="ml-2 overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight">AppName</p>
<p className="text-white/50 text-xs">v1.0.0</p>
</div>
)}
</div>
{/* Menu */}
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1">
{NAV_ITEMS.map((item, i) => (
<NavItemComponent key={i} item={item} collapsed={sidebarCollapsed} />
))}
</nav>
{/* Toggle collapse (desktop only) */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="hidden lg:flex items-center justify-center h-10 border-t border-white/10 text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<Menu size={18} />
</button>
</aside>
</>
);
}
Header dengan User Menu, Notifikasi, dan Search
Header berfungsi sebagai zona kontrol atas yang selalu terlihat. Letakkan breadcrumb di kiri (navigator kontekstual) dan kelompok kontrol di kanan: search global, ikon notifikasi dengan badge counter, dan avatar menu pengguna untuk logout atau ganti profil.
Tips. Search global di header sebaiknya memiliki shortcut keyboard: Ctrl+K atau Cmd+K. Pola ini sudah jadi konvensi di kalangan pengguna profesional dan meningkatkan produktivitas signifikan untuk operator yang bekerja dengan banyak data.
Breadcrumb Dinamis
Breadcrumb yang dibangun dari pathname URL bekerja otomatis tanpa konfigurasi manual per halaman. Gunakan hook usePathname() dari Next.js, pecah berdasarkan karakter /, lalu terjemahkan setiap segmen ke label yang ramah pengguna melalui sebuah map objek.
| Elemen Layout | Fungsi Utama | State Yang Digunakan | Responsif? |
|---|---|---|---|
| Sidebar | Navigasi primer antar modul | Zustand (collapsed, open) | Drawer di mobile |
| Header | Breadcrumb, notifikasi, profil | URL pathname, server state | Selalu tampil |
| Konten Utama | Area kerja halaman aktif | Per-halaman | Scroll vertikal |
| Slide Panel | Detail item tanpa pindah halaman | Zustand (open, itemId) | Full-screen di mobile |
| Modal Kanan | Form tambah/edit | Zustand (open, mode, data) | Full-screen di mobile |
CRUD Halaman Tunggal (SPA-style)
Pola CRUD satu halaman adalah jantung dari hampir setiap aplikasi manajemen: tabel data di tengah, tombol Tambah membuka modal dari kanan, klik baris tabel membuka slide panel detail, dan tombol Edit di dalam panel tersebut mengganti isi modal. Pengguna tidak pernah meninggalkan halaman. Tidak ada loading halaman baru. Semua terjadi dalam satu layar yang terpadu. Pola ini—yang dalam buku ini kita sebut CRUD Halaman Tunggal—adalah rumus wajib yang harus Anda implementasikan konsisten di setiap modul aplikasi.
Arsitektur Komponen CRUD
Halaman CRUD terdiri dari empat komponen utama yang saling berkomunikasi melalui Zustand store:
- DataTable — menampilkan list data dengan sorting, filter, search, dan pagination
- FormModal — panel geser dari kanan untuk form Tambah/Edit
- DetailPanel — slide panel yang lebih lebar untuk tampilan detail lengkap
- ConfirmModal — dialog kecil untuk konfirmasi hapus
// src/stores/useProdukPageStore.ts
import { create } from "zustand";
type ModalMode = "tambah" | "edit";
interface ProdukPageState {
// Form modal (Tambah/Edit) dari kanan
formModalOpen: boolean;
formModalMode: ModalMode;
formModalData: Partial<Produk> | null;
openFormModal: (mode: ModalMode, data?: Partial<Produk>) => void;
closeFormModal: () => void;
// Detail slide panel dari kanan
detailPanelOpen: boolean;
detailPanelId: string | null;
openDetailPanel: (id: string) => void;
closeDetailPanel: () => void;
// Confirm delete modal
deleteModalOpen: boolean;
deleteTargetId: string | null;
openDeleteModal: (id: string) => void;
closeDeleteModal: () => void;
// Filter & search
searchQuery: string;
setSearchQuery: (q: string) => void;
sortField: string;
sortDir: "asc" | "desc";
setSort: (field: string) => void;
currentPage: number;
setCurrentPage: (page: number) => void;
}
export const useProdukPageStore = create<ProdukPageState>((set, get) => ({
formModalOpen: false,
formModalMode: "tambah",
formModalData: null,
openFormModal: (mode, data = null) =>
set({ formModalOpen: true, formModalMode: mode, formModalData: data }),
closeFormModal: () =>
set({ formModalOpen: false, formModalData: null }),
detailPanelOpen: false,
detailPanelId: null,
openDetailPanel: (id) => set({ detailPanelOpen: true, detailPanelId: id }),
closeDetailPanel: () => set({ detailPanelOpen: false, detailPanelId: null }),
deleteModalOpen: false,
deleteTargetId: null,
openDeleteModal: (id) => set({ deleteModalOpen: true, deleteTargetId: id }),
closeDeleteModal: () => set({ deleteModalOpen: false, deleteTargetId: null }),
searchQuery: "",
setSearchQuery: (q) => set({ searchQuery: q, currentPage: 1 }),
sortField: "created_at",
sortDir: "desc",
setSort: (field) =>
set((s) => ({
sortField: field,
sortDir: s.sortField === field && s.sortDir === "asc" ? "desc" : "asc",
})),
currentPage: 1,
setCurrentPage: (page) => set({ currentPage: page }),
}));
Data Table dengan Sorting, Filter, dan Search
Tabel data yang baik harus mendukung tiga interaksi dasar: pencarian teks bebas, filter by kolom tertentu, dan sorting dengan klik header. Semua parameter ini dikirim ke API sebagai query string, sehingga server yang melakukan filtering—bukan client. Ini penting untuk dataset besar di atas 10.000 baris.
// src/features/produk/components/ProdukTable.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useProdukPageStore } from "@/stores/useProdukPageStore";
import { ChevronUp, ChevronDown, Eye, Pencil, Trash2, Plus } from "lucide-react";
import { clsx } from "clsx";
import { formatRupiah } from "@/lib/utils";
interface Produk {
id: string;
nama: string;
sku: string;
harga: number;
stok: number;
kategori: { nama: string };
updated_at: string;
}
interface ProdukListResponse {
data: Produk[];
meta: { total: number; per_page: number; current_page: number; last_page: number };
}
export function ProdukTable() {
const {
searchQuery, setSearchQuery,
sortField, sortDir, setSort,
currentPage, setCurrentPage,
openFormModal, openDetailPanel, openDeleteModal,
} = useProdukPageStore();
const { data, isLoading, isFetching } = useQuery<ProdukListResponse>({
queryKey: ["produk", { searchQuery, sortField, sortDir, currentPage }],
queryFn: () =>
api.get("/produk", {
params: { q: searchQuery, sort: sortField, dir: sortDir, page: currentPage, per_page: 20 },
}).then(r => r.data),
placeholderData: (prev) => prev, // Tampilkan data lama saat fetching baru
});
const SortIcon = ({ field }: { field: string }) =>
sortField === field ? (
sortDir === "asc" ? <ChevronUp size={14} /> : <ChevronDown size={14} />
) : (
<ChevronDown size={14} className="opacity-30" />
);
return (
<div>
{/* Toolbar */}
<div className="flex items-center justify-between mb-4 gap-3">
<input
type="text"
placeholder="Cari produk, SKU..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={() => openFormModal("tambah")}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
<Plus size={16} /> Tambah Produk
</button>
</div>
{/* Tabel */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className={clsx("transition-opacity", isFetching && "opacity-60")}>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{[
{ key: "nama", label: "Nama Produk" },
{ key: "sku", label: "SKU" },
{ key: "harga", label: "Harga" },
{ key: "stok", label: "Stok" },
{ key: "updated_at", label: "Diperbarui" },
].map(col => (
<th
key={col.key}
onClick={() => setSort(col.key)}
className="text-left px-4 py-3 font-medium text-gray-600 cursor-pointer hover:text-gray-900 select-none"
>
<span className="flex items-center gap-1">
{col.label} <SortIcon field={col.key} />
</span>
</th>
))}
<th className="px-4 py-3 text-right font-medium text-gray-600">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" />
</td>
))}
</tr>
))
) : (
data?.data.map(produk => (
<tr
key={produk.id}
className="hover:bg-blue-50/30 cursor-pointer transition-colors"
onClick={() => openDetailPanel(produk.id)}
>
<td className="px-4 py-3 font-medium text-gray-900">{produk.nama}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{produk.sku}</td>
<td className="px-4 py-3 text-gray-700">{formatRupiah(produk.harga)}</td>
<td className="px-4 py-3">
<span className={clsx(
"px-2 py-0.5 rounded-full text-xs font-medium",
produk.stok > 10 ? "bg-green-100 text-green-700"
: produk.stok > 0 ? "bg-yellow-100 text-yellow-700"
: "bg-red-100 text-red-700"
)}>
{produk.stok} unit
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{new Date(produk.updated_at).toLocaleDateString("id-ID")}
</td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openDetailPanel(produk.id)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors"
title="Lihat Detail"
>
<Eye size={15} />
</button>
<button
onClick={() => openFormModal("edit", produk)}
className="p-1.5 text-gray-400 hover:text-amber-600 hover:bg-amber-50 rounded-md transition-colors"
title="Edit"
>
<Pencil size={15} />
</button>
<button
onClick={() => openDeleteModal(produk.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Hapus"
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data?.meta && (
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between text-sm text-gray-600">
<span>
Menampilkan {(data.meta.current_page - 1) * data.meta.per_page + 1}–
{Math.min(data.meta.current_page * data.meta.per_page, data.meta.total)} dari {data.meta.total} data
</span>
<div className="flex gap-1">
{Array.from({ length: data.meta.last_page }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={clsx(
"w-8 h-8 rounded-md text-sm transition-colors",
page === data.meta.current_page
? "bg-blue-600 text-white"
: "hover:bg-gray-100 text-gray-600"
)}
>
{page}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}
Optimistic Updates
Optimistic update adalah teknik di mana UI diperbarui sebelum respons server tiba. Ketika pengguna mengklik “Hapus” misalnya, baris langsung menghilang dari tabel. Jika server mengembalikan error, data dikembalikan ke kondisi semula. Ini membuat aplikasi terasa instan bahkan pada koneksi lambat.
Awas. Jangan gunakan optimistic update untuk operasi yang tidak dapat dibalik, seperti pembayaran atau pengiriman email. Gunakan hanya untuk CRUD data biasa di mana kegagalan bisa di-rollback dengan mulus.
Real-time Update dengan WebSocket
Untuk aplikasi yang digunakan banyak pengguna secara bersamaan—misalnya sistem kasir di toko dengan dua operator—tabel perlu ter-update otomatis ketika pengguna lain membuat perubahan. Gunakan WebSocket atau Server-Sent Events (SSE) dikombinasikan dengan queryClient.invalidateQueries() React Query untuk me-refresh data tanpa full page reload.
Form Kompleks
Form adalah titik di mana pengguna memasukkan data ke dalam sistem Anda. Form yang buruk menghasilkan data kotor, frustrasi pengguna, dan dukungan teknis yang tidak ada habisnya. Form yang baik memvalidasi input secara real-time, memberikan pesan kesalahan yang jelas, menangani kasus tepi seperti upload file besar atau field yang muncul/menghilang secara kondisional, dan menyimpan draft otomatis agar pengguna tidak kehilangan pekerjaan mereka jika browser tiba-tiba ditutup. Bab ini membahas seluruh skenario ini dengan implementasi nyata.
Validasi Form dengan Zod dan React Hook Form
Zod adalah library validasi TypeScript-first yang memungkinkan Anda mendefinisikan schema data sekali dan menggunakannya di frontend maupun backend. Ketika schema berubah—misalnya Anda menambahkan field wajib baru—TypeScript langsung memperingatkan semua tempat yang belum diperbarui.
// src/features/pembelian/schemas/pembelianSchema.ts
import { z } from "zod";
// Schema untuk satu item dalam pesanan
const ItemPesananSchema = z.object({
produk_id: z.string().uuid("Pilih produk yang valid"),
qty: z.number().int().min(1, "Jumlah minimal 1").max(9999),
harga_satuan: z.number().min(0),
diskon_persen: z.number().min(0).max(100).default(0),
});
// Schema utama pesanan pembelian
export const PembelianSchema = z.object({
nomor_po: z.string().min(1, "Nomor PO wajib diisi"),
supplier_id: z.string().uuid("Pilih supplier terlebih dahulu"),
tanggal_po: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: "Format tanggal tidak valid",
}),
tanggal_jatuh_tempo: z.string().optional(),
items: z.array(ItemPesananSchema)
.min(1, "Tambahkan minimal satu item produk")
.max(50, "Maksimal 50 item per pesanan"),
catatan: z.string().max(500, "Catatan maksimal 500 karakter").optional(),
lampiran_id: z.string().uuid().optional().nullable(),
}).refine(
(data) => {
if (!data.tanggal_jatuh_tempo) return true;
return new Date(data.tanggal_jatuh_tempo) > new Date(data.tanggal_po);
},
{
message: "Tanggal jatuh tempo harus setelah tanggal PO",
path: ["tanggal_jatuh_tempo"],
}
);
export type PembelianFormData = z.infer<typeof PembelianSchema>;
Dynamic Form Fields (Field Array)
Form pesanan pembelian membutuhkan field items yang bisa ditambah dan dihapus secara dinamis. React Hook Form menyediakan hook useFieldArray untuk skenario ini. Setiap baris item mendapatkan index-nya sendiri, validasi berjalan per-baris, dan total harga dihitung otomatis menggunakan watch().
// src/features/pembelian/components/PembelianForm.tsx
"use client";
import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { PembelianSchema, PembelianFormData } from "../schemas/pembelianSchema";
import { Plus, Trash2 } from "lucide-react";
import { formatRupiah } from "@/lib/utils";
export function PembelianForm({ onSuccess }: { onSuccess: () => void }) {
const {
register,
control,
handleSubmit,
watch,
formState: { errors },
} = useForm<PembelianFormData>({
resolver: zodResolver(PembelianSchema),
defaultValues: {
items: [{ produk_id: "", qty: 1, harga_satuan: 0, diskon_persen: 0 }],
},
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
// Hitung subtotal real-time
const watchedItems = useWatch({ control, name: "items" });
const totalHarga = watchedItems?.reduce((sum, item) => {
const subtotal = (item.qty || 0) * (item.harga_satuan || 0);
const diskon = subtotal * ((item.diskon_persen || 0) / 100);
return sum + subtotal - diskon;
}, 0) ?? 0;
const onSubmit = async (data: PembelianFormData) => {
// Submit ke API
console.log(data);
onSuccess();
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Header fields */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nomor PO *</label>
<input {...register("nomor_po")} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
{errors.nomor_po && <p className="text-xs text-red-600 mt-1">{errors.nomor_po.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tanggal PO *</label>
<input type="date" {...register("tanggal_po")} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
</div>
</div>
{/* Item list */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-700">Daftar Item</h4>
<button
type="button"
onClick={() => append({ produk_id: "", qty: 1, harga_satuan: 0, diskon_persen: 0 })}
className="flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
>
<Plus size={14} /> Tambah Item
</button>
</div>
<div className="space-y-2">
{fields.map((field, index) => {
const qty = watchedItems?.[index]?.qty ?? 0;
const harga = watchedItems?.[index]?.harga_satuan ?? 0;
const diskon = watchedItems?.[index]?.diskon_persen ?? 0;
const subtotal = qty * harga * (1 - diskon / 100);
return (
<div key={field.id} className="grid grid-cols-12 gap-2 items-start bg-gray-50 p-3 rounded-lg">
<div className="col-span-4">
<select
{...register(`items.${index}.produk_id`)}
className="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm"
>
<option value="">Pilih produk...</option>
{/* Options dari API */}
</select>
</div>
<div className="col-span-2">
<input
type="number"
{...register(`items.${index}.qty`, { valueAsNumber: true })}
placeholder="Qty"
className="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm"
/>
</div>
<div className="col-span-3">
<input
type="number"
{...register(`items.${index}.harga_satuan`, { valueAsNumber: true })}
placeholder="Harga satuan"
className="w-full border border-gray-300 rounded-md px-2 py-1.5 text-sm"
/>
</div>
<div className="col-span-2 text-right text-sm font-medium text-gray-700 pt-2">
{formatRupiah(subtotal)}
</div>
<div className="col-span-1 flex justify-center">
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
);
})}
</div>
{errors.items?.root && (
<p className="text-xs text-red-600 mt-2">{errors.items.root.message}</p>
)}
{/* Total */}
<div className="flex justify-end mt-3 pt-3 border-t border-gray-200">
<div className="text-right">
<p className="text-xs text-gray-500">Total Pesanan</p>
<p className="text-xl font-bold text-blue-700">{formatRupiah(totalHarga)}</p>
</div>
</div>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2.5 rounded-lg text-sm font-semibold hover:bg-blue-700 transition-colors"
>
Simpan Pesanan Pembelian
</button>
</form>
);
}
File Upload dengan Preview
Upload file membutuhkan dua langkah: pertama upload file ke server (atau langsung ke object storage seperti S3/R2) untuk mendapatkan URL atau ID, kemudian ID tersebut disimpan bersama data form utama. Jangan pernah mengirim file sebagai base64 di dalam JSON body—ukurannya membengkak 33% dan menyumbat request.
Rumus. Pola upload yang benar:
POST /api/upload→ terima{ file_id, url }→ simpanfile_idke field form → saat form di-submit, kirim hanyafile_id, bukan data file. Server backend yang menghubungkan ke file viafile_id.
Multi-step Form
Form dengan lebih dari delapan field sebaiknya dipecah menjadi beberapa langkah. React Hook Form mendukung ini dengan menyimpan semua data di satu form context—Anda cukup memvalidasi field dari step yang aktif saja sebelum maju ke step berikutnya, tanpa kehilangan data dari step sebelumnya.
Auto-save Draft
Untuk form panjang seperti pengajuan kredit atau laporan inspeksi, auto-save draft mencegah kehilangan data jika pengguna tidak sengaja menutup tab. Implementasinya menggunakan useWatch yang menyimpan nilai form ke localStorage setiap kali ada perubahan, dengan debounce dua detik agar tidak terlalu sering menulis.
Dashboard & Analytics
Dashboard adalah wajah dari aplikasi Anda. Ketika seorang manajer distro di Medan membuka laptop pagi hari, halaman pertama yang dilihat menentukan apakah ia mendapatkan insight yang dibutuhkan untuk mengambil keputusan hari ini, atau hanya melihat angka-angka besar yang tidak ia mengerti. Dashboard yang baik bukan tentang menampilkan sebanyak mungkin data; ia tentang menampilkan data yang tepat, dalam format yang tepat, kepada orang yang tepat.
KPI Cards: Metrik Utama di Atas Halaman
Empat hingga enam KPI card di baris teratas adalah standar industri. Setiap card menampilkan: nilai utama (besar dan kontras), label metrik, perubahan dibanding periode sebelumnya (delta dengan warna hijau/merah), dan ikon yang relevan. Komponen ini harus generik dan dapat dikonfigurasi:
// src/components/ui/KpiCard.tsx
import { clsx } from "clsx";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
interface KpiCardProps {
label: string;
value: string;
delta?: number; // Persentase perubahan vs periode sebelumnya
deltaLabel?: string; // Mis. "vs bulan lalu"
icon?: React.ReactNode;
loading?: boolean;
}
export function KpiCard({ label, value, delta, deltaLabel = "vs bulan lalu", icon, loading }: KpiCardProps) {
if (loading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-24 mb-3" />
<div className="h-8 bg-gray-200 rounded w-32 mb-2" />
<div className="h-3 bg-gray-200 rounded w-20" />
</div>
);
}
const isPositive = (delta ?? 0) > 0;
const isNegative = (delta ?? 0) < 0;
const isNeutral = delta === undefined || delta === 0;
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-gray-500">{label}</p>
{icon && (
<div className="w-9 h-9 bg-blue-50 rounded-lg flex items-center justify-center text-blue-600">
{icon}
</div>
)}
</div>
<p className="text-2xl font-bold text-gray-900 mb-1">{value}</p>
{delta !== undefined && (
<div className={clsx(
"flex items-center gap-1 text-xs font-medium",
isPositive ? "text-green-600" : isNegative ? "text-red-600" : "text-gray-500"
)}>
{isPositive ? <TrendingUp size={12} /> : isNegative ? <TrendingDown size={12} /> : <Minus size={12} />}
<span>{isPositive ? "+" : ""}{delta.toFixed(1)}% {deltaLabel}</span>
</div>
)}
</div>
);
}
Grafik dengan Recharts
Recharts adalah library chart paling populer untuk React karena API-nya deklaratif dan terintegrasi alami dengan ekosistem React. Untuk MVP, Recharts sudah lebih dari cukup. Jika butuh chart yang lebih kompleks atau dengan performa tinggi untuk dataset jutaan baris, pertimbangkan ECharts (Apache).
// src/features/dashboard/components/PenjualanChart.tsx
"use client";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend, Area, AreaChart
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
interface DataPoint {
tanggal: string;
total_penjualan: number;
total_transaksi: number;
}
function formatRupiahShort(value: number): string {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}M`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}jt`;
if (value >= 1_000) return `${(value / 1_000).toFixed(0)}rb`;
return value.toString();
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-lg p-3 text-sm">
<p className="font-semibold text-gray-700 mb-2">{label}</p>
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ background: p.color }} />
<span className="text-gray-600">{p.name}:</span>
<span className="font-medium">
{p.dataKey === "total_penjualan"
? `Rp ${p.value.toLocaleString("id-ID")}`
: p.value}
</span>
</div>
))}
</div>
);
};
export function PenjualanChart({ periode = "30d" }: { periode?: string }) {
const { data, isLoading } = useQuery<DataPoint[]>({
queryKey: ["dashboard", "penjualan-chart", periode],
queryFn: () => api.get("/dashboard/penjualan", { params: { periode } }).then(r => r.data),
staleTime: 5 * 60 * 1000, // Cache 5 menit
});
if (isLoading) {
return <div className="h-72 bg-gray-100 rounded-xl animate-pulse" />;
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-700">Tren Penjualan</h3>
<span className="text-xs text-gray-400">30 hari terakhir</span>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorPenjualan" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1e3a8a" stopOpacity={0.15} />
<stop offset="95%" stopColor="#1e3a8a" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f4ff" />
<XAxis
dataKey="tanggal"
tick={{ fontSize: 11, fill: "#94a3b8" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={formatRupiahShort}
tick={{ fontSize: 11, fill: "#94a3b8" }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="total_penjualan"
name="Penjualan"
stroke="#1e3a8a"
strokeWidth={2}
fill="url(#colorPenjualan)"
dot={false}
activeDot={{ r: 5, fill: "#1e3a8a" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
Export PDF dari Dashboard
Fitur export PDF sering diminta oleh klien korporat dan pemerintahan yang perlu melampirkan laporan ke email atau rapat. Ada dua pendekatan: client-side dengan jsPDF + html2canvas (mudah tetapi kualitas cetak terbatas), atau server-side dengan Puppeteer/Playwright yang merender halaman sepenuhnya (kualitas tinggi, butuh server).
Tips. Untuk MVP, gunakan pendekatan server-side dengan endpoint
GET /laporan/:id/export-pdfyang merender template HTML di server menggunakan Puppeteer, lalu mengembalikan file PDF. Hasilnya jauh lebih rapi dibanding html2canvas, terutama untuk tabel dengan banyak kolom.
| Library Chart | Lisensi | Kelebihan | Kekurangan | Rekomendasi MVP |
|---|---|---|---|---|
| Recharts | MIT | API React-native, mudah kustomisasi | Performa turun di >10K data points | Ya — default pilihan |
| Chart.js | MIT | Mature, dokumentasi lengkap, animasi halus | Tidak React-native, perlu wrapper | Jika sudah familiar |
| Apache ECharts | Apache 2.0 | Performa tinggi, chart kompleks (scatter, heatmap) | Bundle besar, API verbose | Jika butuh chart kompleks |
| Tremor | Apache 2.0 | Komponen dashboard siap pakai + Tailwind | Kustomisasi terbatas | Untuk prototipe cepat |
| Nivo | MIT | Chart responsif, SVG & Canvas | Bundle besar jika pakai semua chart | Jika butuh radial/sankey |
State Management
State management adalah salah satu topik yang paling sering memicu perdebatan panjang di komunitas React. Pilihan yang salah di awal proyek menciptakan hutang teknis yang mahal: store yang terlalu besar membuat debugging sulit, store yang terlalu kecil memaksa Anda prop-drilling enam level ke bawah, dan mencampur server state dengan client state menghasilkan bug sinkronisasi yang susah direproduksi. Bab ini memberikan panduan yang jelas: apa yang perlu di-store, di mana, dan mengapa.
Dua Jenis State yang Berbeda
Kesalahan paling fundamental dalam state management adalah memperlakukan semua state sama. Sebenarnya ada dua jenis state dengan karakteristik yang sangat berbeda:
Server state adalah data yang bersumber dari server: daftar produk, data pengguna, laporan penjualan. Karakteristiknya: asynchronous (harus di-fetch), bisa basi (harus di-refresh), dan bisa diubah oleh pengguna lain. Client state adalah data UI murni: apakah sidebar sedang terbuka, nilai field pencarian yang belum dikirim, tab mana yang aktif. Karakteristiknya: synchronous, hanya ada di browser pengguna ini, dan tidak perlu di-sync ke server.
Rumus. Gunakan React Query / SWR untuk server state. Gunakan Zustand / Context untuk client state. Jangan gunakan Redux untuk server state—itu tugas React Query. Jangan gunakan React Query untuk UI state—itu tugas Zustand.
Zustand: Rekomendasi untuk MVP
Zustand memiliki API yang paling minimal di antara semua state management library untuk React. Store didefinisikan sebagai satu fungsi, tidak ada action types, tidak ada reducer, tidak ada dispatch. Anda langsung memanggil fungsi untuk mengubah state.
// src/stores/useUIStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UIState {
// Sidebar
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
mobileSidebarOpen: boolean;
setMobileSidebarOpen: (v: boolean) => void;
// Global modal/panel
activeModal: string | null;
openModal: (name: string) => void;
closeModal: () => void;
// Tema (light/dark — future feature)
theme: "light" | "dark";
setTheme: (t: "light" | "dark") => void;
}
// persist middleware menyimpan sebagian state ke localStorage
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
setSidebarCollapsed: (v) => set({ sidebarCollapsed: v }),
mobileSidebarOpen: false,
setMobileSidebarOpen: (v) => set({ mobileSidebarOpen: v }),
activeModal: null,
openModal: (name) => set({ activeModal: name }),
closeModal: () => set({ activeModal: null }),
theme: "light",
setTheme: (t) => set({ theme: t }),
}),
{
name: "ui-store",
// Hanya persist sidebarCollapsed dan theme
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
}),
}
)
);
// src/stores/useAuthStore.ts — Contoh store autentikasi
import { create } from "zustand";
interface User {
id: string;
nama: string;
email: string;
role: "admin" | "operator" | "viewer";
foto_url?: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setUser: (user: User, token: string) => void;
logout: () => void;
updateUser: (partial: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
setUser: (user, token) =>
set({ user, token, isAuthenticated: true }),
logout: () =>
set({ user: null, token: null, isAuthenticated: false }),
updateUser: (partial) =>
set((state) => ({
user: state.user ? { ...state.user, ...partial } : null,
})),
}),
{ name: "auth-store" }
)
);
Redux Toolkit: Untuk Aplikasi Besar
Redux Toolkit (RTK) adalah pilihan tepat ketika aplikasi Anda melampaui skala MVP: lebih dari 10 developer, fitur time-travel debugging yang krusial (misalnya untuk aplikasi keuangan yang perlu audit trail state), atau integrasi dengan middleware yang kompleks seperti RTK Query dengan normalisasi cache. RTK mengeliminasi boilerplate Redux klasik dengan createSlice() yang menggabungkan action types, actions, dan reducer dalam satu deklarasi.
// src/store/slices/notifikasiSlice.ts (contoh RTK slice)
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { api } from "@/lib/api";
interface Notifikasi {
id: string;
judul: string;
isi: string;
dibaca: boolean;
created_at: string;
}
interface NotifikasiState {
items: Notifikasi[];
unreadCount: number;
loading: boolean;
error: string | null;
}
// Async thunk untuk fetch notifikasi
export const fetchNotifikasi = createAsyncThunk(
"notifikasi/fetch",
async (_, { rejectWithValue }) => {
try {
const res = await api.get("/notifikasi");
return res.data as Notifikasi[];
} catch (err: any) {
return rejectWithValue(err.response?.data?.message ?? "Gagal memuat notifikasi");
}
}
);
const notifikasiSlice = createSlice({
name: "notifikasi",
initialState: {
items: [],
unreadCount: 0,
loading: false,
error: null,
} as NotifikasiState,
reducers: {
tandaiBacaSatu: (state, action: PayloadAction<string>) => {
const item = state.items.find(n => n.id === action.payload);
if (item && !item.dibaca) {
item.dibaca = true;
state.unreadCount = Math.max(0, state.unreadCount - 1);
}
},
tandaiBacaSemua: (state) => {
state.items.forEach(n => { n.dibaca = true; });
state.unreadCount = 0;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchNotifikasi.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchNotifikasi.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
state.unreadCount = action.payload.filter(n => !n.dibaca).length;
})
.addCase(fetchNotifikasi.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
export const { tandaiBacaSatu, tandaiBacaSemua } = notifikasiSlice.actions;
export default notifikasiSlice.reducer;
Context API: Untuk State Sederhana dan Terlokalisasi
Context API bawaan React cocok untuk state yang hanya relevan dalam satu subtree komponen—misalnya konfigurasi theme provider, state form multi-step yang tidak perlu dibagikan ke luar form, atau data pengguna yang di-fetch sekali saat login. Hindari Context untuk state yang sering berubah karena setiap perubahan me-render ulang semua consumer.
| Aspek | Zustand | Redux Toolkit | Context API |
|---|---|---|---|
| Setup boilerplate | Minimal (5 baris) | Sedang (store + slice + provider) | Minimal |
| DevTools | Zustand DevTools (via middleware) | Redux DevTools (time-travel) | Tidak ada |
| TypeScript | Sangat baik | Sangat baik | Baik (manual typing) |
| Performa re-render | Optimal (selector granular) | Optimal (useSelector) | Perlu memoization manual |
| Persistence | Built-in middleware | redux-persist | Manual |
| Cocok untuk | MVP hingga menengah | Tim besar, app kompleks | State lokal, tema |
Testing Frontend
Testing frontend sering diabaikan di proyek MVP dengan alasan “nanti saja kalau sudah besar.” Ini adalah keputusan yang mahal. Tanpa test, refactoring menjadi perjudian—Anda tidak tahu apakah perubahan di satu komponen merusak komponen lain yang bergantung padanya. Dengan test yang tepat, Anda bisa me-refactor dengan percaya diri, onboarding developer baru lebih cepat, dan mendeploy lebih sering. Bab ini membahas strategi testing yang realistis untuk tim kecil: fokus pada komponen kritikal, otomasi yang bernilai, dan toolchain yang efisien.
Toolchain Testing Frontend Modern
Stack testing yang direkomendasikan untuk proyek Next.js + TypeScript:
- Vitest — test runner pengganti Jest yang 10-20x lebih cepat, native ESM, dan konfigurasi minimal
- React Testing Library (RTL) — test komponen dari perspektif pengguna, bukan implementasi internal
- Playwright — E2E testing di browser nyata (Chromium, Firefox, WebKit)
- MSW (Mock Service Worker) — intercept HTTP request di test sehingga komponen ditest dengan data realistis tanpa memanggil API nyata
# vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
exclude: [
"node_modules/",
"src/test/",
"**/*.d.ts",
"**/*.config.*",
"**/types.ts",
],
thresholds: {
lines: 70,
functions: 70,
branches: 60,
},
},
},
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
});
Unit Test dengan Vitest
Unit test paling bernilai untuk fungsi utilitas yang memiliki banyak edge case: format angka, validasi schema, kalkulasi bisnis, dan transformasi data. Jangan test implementasi detail seperti nama variabel internal—test perilaku yang terlihat dari luar.
// src/lib/__tests__/utils.test.ts
import { describe, it, expect } from "vitest";
import { formatRupiah, hitungDiskon, truncateText } from "../utils";
describe("formatRupiah", () => {
it("memformat angka biasa dengan benar", () => {
expect(formatRupiah(150000)).toBe("Rp 150.000");
expect(formatRupiah(1500000)).toBe("Rp 1.500.000");
});
it("memformat angka nol", () => {
expect(formatRupiah(0)).toBe("Rp 0");
});
it("menangani angka negatif", () => {
expect(formatRupiah(-50000)).toBe("-Rp 50.000");
});
it("menangani angka desimal (dibulatkan)", () => {
expect(formatRupiah(15999.99)).toBe("Rp 15.999");
});
});
describe("hitungDiskon", () => {
it("menghitung diskon persentase dengan benar", () => {
expect(hitungDiskon(100000, 10)).toBe(90000);
expect(hitungDiskon(200000, 25)).toBe(150000);
});
it("mengembalikan harga asli jika diskon 0", () => {
expect(hitungDiskon(100000, 0)).toBe(100000);
});
it("mengembalikan 0 jika diskon 100%", () => {
expect(hitungDiskon(100000, 100)).toBe(0);
});
it("melempar error jika diskon di luar 0-100", () => {
expect(() => hitungDiskon(100000, 110)).toThrow("Diskon tidak boleh lebih dari 100%");
expect(() => hitungDiskon(100000, -5)).toThrow("Diskon tidak boleh negatif");
});
});
describe("truncateText", () => {
it("tidak memotong teks yang pendek", () => {
expect(truncateText("Halo", 10)).toBe("Halo");
});
it("memotong teks panjang dan menambahkan ellipsis", () => {
expect(truncateText("Teks yang sangat panjang sekali", 15)).toBe("Teks yang san...");
});
it("menangani string kosong", () => {
expect(truncateText("", 10)).toBe("");
});
});
Component Test dengan React Testing Library
RTL mendorong Anda untuk mengtest komponen dari sudut pandang pengguna: apakah teks tertentu terlihat? Apakah tombol bisa diklik? Apakah form memunculkan pesan error ketika field kosong dikosongkan? Hindari mengtest state internal komponen atau nama method—itu adalah implementasi detail yang bisa berubah kapan saja.
// src/components/ui/__tests__/KpiCard.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { KpiCard } from "../KpiCard";
import { ShoppingCart } from "lucide-react";
describe("KpiCard", () => {
it("menampilkan label dan nilai", () => {
render(<KpiCard label="Total Penjualan" value="Rp 15.000.000" />);
expect(screen.getByText("Total Penjualan")).toBeInTheDocument();
expect(screen.getByText("Rp 15.000.000")).toBeInTheDocument();
});
it("menampilkan delta positif dengan warna hijau", () => {
render(<KpiCard label="Transaksi" value="245" delta={12.5} />);
const deltaEl = screen.getByText("+12.5% vs bulan lalu");
expect(deltaEl).toHaveClass("text-green-600");
});
it("menampilkan delta negatif dengan warna merah", () => {
render(<KpiCard label="Stok" value="80 unit" delta={-8.3} />);
const deltaEl = screen.getByText("-8.3% vs bulan lalu");
expect(deltaEl).toHaveClass("text-red-600");
});
it("menampilkan skeleton saat loading", () => {
render(<KpiCard label="Test" value="0" loading />);
// Saat loading, nilai tidak ditampilkan
expect(screen.queryByText("0")).not.toBeInTheDocument();
// Animasi skeleton ada
expect(document.querySelector(".animate-pulse")).toBeInTheDocument();
});
it("menampilkan icon jika diberikan", () => {
render(
<KpiCard
label="Pesanan"
value="42"
icon={<ShoppingCart data-testid="icon-cart" />}
/>
);
expect(screen.getByTestId("icon-cart")).toBeInTheDocument();
});
});
E2E Testing dengan Playwright
Playwright menjalankan test di browser nyata, memungkinkan Anda memverifikasi alur lengkap: login, navigasi ke halaman produk, klik tombol Tambah, isi form, submit, dan verifikasi data muncul di tabel. Test ini lebih lambat dari unit test tetapi memberikan keyakinan tertinggi bahwa fitur bekerja end-to-end.
// tests/e2e/produk.spec.ts
import { test, expect } from "@playwright/test";
test.describe("CRUD Produk", () => {
test.beforeEach(async ({ page }) => {
// Login sebelum setiap test
await page.goto("/login");
await page.fill('[name="email"]', "admin@test.com");
await page.fill('[name="password"]', "password123");
await page.click('button[type="submit"]');
await page.waitForURL("/dashboard");
});
test("menampilkan daftar produk", async ({ page }) => {
await page.goto("/produk");
await expect(page.getByRole("heading", { name: /produk/i })).toBeVisible();
// Tabel harus muncul
await expect(page.getByRole("table")).toBeVisible();
});
test("menambah produk baru via modal kanan", async ({ page }) => {
await page.goto("/produk");
// Klik tombol Tambah
await page.getByRole("button", { name: /tambah produk/i }).click();
// Modal harus terbuka dari kanan
const modal = page.getByRole("dialog");
await expect(modal).toBeVisible();
// Isi form
await page.fill('[name="nama"]', "Mie Instan Goreng");
await page.fill('[name="sku"]', "MIE-001");
await page.fill('[name="harga"]', "3500");
await page.fill('[name="stok"]', "500");
// Submit
await page.getByRole("button", { name: /tambah produk/i }).last().click();
// Modal harus menutup
await expect(modal).not.toBeVisible();
// Produk baru muncul di tabel
await expect(page.getByText("Mie Instan Goreng")).toBeVisible();
});
test("dapat mencari produk", async ({ page }) => {
await page.goto("/produk");
const searchInput = page.getByPlaceholder(/cari produk/i);
await searchInput.fill("Beras");
// Tunggu debounce dan request selesai
await page.waitForResponse(r => r.url().includes("/produk") && r.status() === 200);
// Hanya produk yang mengandung "Beras" yang muncul
const rows = page.locator("tbody tr");
const count = await rows.count();
for (let i = 0; i < count; i++) {
await expect(rows.nth(i)).toContainText(/beras/i);
}
});
test("konfirmasi sebelum menghapus produk", async ({ page }) => {
await page.goto("/produk");
// Klik tombol hapus di baris pertama
await page.locator("tbody tr").first().getByTitle("Hapus").click();
// Dialog konfirmasi harus muncul
await expect(page.getByRole("dialog")).toContainText(/yakin.*hapus/i);
// Batalkan
await page.getByRole("button", { name: /batal/i }).click();
// Data tidak terhapus, tabel masih ada
await expect(page.getByRole("table")).toBeVisible();
});
});
Visual Regression Testing
Visual regression testing mendeteksi perubahan tampilan yang tidak disengaja—misalnya padding yang bergeser 2px karena perubahan di Tailwind config, atau warna button yang berubah karena update library. Playwright mendukung screenshot comparison bawaan. Untuk alur yang lebih lengkap, gunakan Chromatic (integrasi Storybook) atau Percy.
Tips. Di MVP, cukup terapkan unit test untuk fungsi utilitas dan component test untuk komponen yang paling sering digunakan (KPI Card, DataTable, Form). E2E test untuk alur kritis saja: login, tambah data, hapus data. Visual regression bisa ditambahkan saat aplikasi sudah stabil.
| Jenis Test | Tool | Kecepatan | Keyakinan | Target Coverage MVP |
|---|---|---|---|---|
| Unit (fungsi) | Vitest | Sangat cepat (<1 detik) | Rendah-Sedang | 90%+ untuk utils |
| Component | Vitest + RTL | Cepat (1-5 detik) | Sedang | Komponen UI utama |
| Integration | Vitest + MSW | Sedang (5-15 detik) | Tinggi | Alur form kritis |
| E2E | Playwright | Lambat (30s-5 menit) | Sangat Tinggi | 5-10 alur kritis |
| Visual Regression | Playwright / Chromatic | Lambat | Sedang | Post-MVP |
Inti. Testing bukan tentang mencapai 100% coverage—angka itu tidak berarti jika testnya tidak bermakna. Testing adalah tentang membangun jaring pengaman yang membuat Anda berani mengubah kode. Mulai dari fungsi yang paling sering Anda sentuh dan alur yang paling sering pengguna lakukan.
Database Implementasi
Migrations & Schema Design
Migration adalah fondasi tak terlihat dari setiap aplikasi yang hidup lama. Ketika kode berubah, skema database harus berubah pula — secara terkontrol, terurut, dan dapat diulang di setiap lingkungan: lokal pengembang, staging, hingga produksi. Tanpa migration, perubahan skema menjadi operasi berbahaya yang dilakukan secara manual, berbeda di setiap server, dan tidak pernah bisa di-rollback dengan aman. Bab ini membahas migration tools terbaik untuk masing-masing ekosistem, strategi versioning skema, rollback yang aman, dan seed data untuk lingkungan pengembangan.
Mengapa Migration Wajib Ada Sejak Hari Pertama
Setiap kali Anda menambah kolom baru, mengubah tipe data, menghapus tabel usang, atau menambahkan indeks, terjadi perubahan skema. Jika perubahan ini dilakukan secara manual dengan mengetik SQL langsung ke konsol production, Anda telah menciptakan drift — kondisi di mana skema di berbagai lingkungan tidak identik. Drift adalah akar dari bug yang sulit direproduksi: “Di lokal saya jalan, di production error.”
Migration menjawab masalah ini dengan tiga prinsip: versioning (setiap perubahan diberi nomor urut), idempotency (menjalankan migration yang sama dua kali tidak merusak data), dan rollback (setiap perubahan dapat dibatalkan ke versi sebelumnya). Ketiga prinsip ini adalah jaminan keamanan saat Anda perlu deploy ke production di tengah malam.
Prisma Migrate — Ekosistem TypeScript/Node.js
Prisma adalah ORM modern untuk Node.js dan TypeScript yang menggunakan file skema deklaratif sebagai sumber kebenaran tunggal. Prisma Migrate menghasilkan file SQL dari perbedaan antara skema saat ini dan skema target.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Produk {
id String @id @default(cuid())
kode String @unique
nama String
harga Decimal @db.Decimal(15, 2)
stok Int @default(0)
kategoriId String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
createdBy String? @map("created_by")
updatedBy String? @map("updated_by")
@@map("produk")
}
model Kategori {
id String @id @default(cuid())
nama String @unique
slug String @unique
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@map("kategori")
}
Setelah skema didefinisikan, jalankan perintah berikut untuk membuat migration:
# Buat migration baru dengan nama deskriptif
npx prisma migrate dev --name tambah_tabel_produk
# Terapkan migration ke database production (tanpa prompt interaktif)
npx prisma migrate deploy
# Reset database dan jalankan ulang semua migration (HANYA untuk development)
npx prisma migrate reset
# Lihat status migration yang sudah dan belum diterapkan
npx prisma migrate status
Prisma menyimpan riwayat migration di folder prisma/migrations/. Setiap folder berisi file migration.sql yang berisi SQL murni — Anda bisa membacanya, mereviewnya di pull request, dan memahami persis apa yang akan dieksekusi di production.
Laravel Migration — Ekosistem PHP
Laravel hadir dengan sistem migration yang matang dan terintegrasi sempurna dengan Eloquent ORM. Migration Laravel menggunakan PHP untuk mendefinisikan skema, sehingga lebih mudah dipahami oleh developer PHP tanpa harus menulis SQL secara langsung.
<?php
// database/migrations/2024_01_15_000001_create_produk_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('produk', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('kode', 50)->unique();
$table->string('nama', 255);
$table->decimal('harga', 15, 2);
$table->unsignedInteger('stok')->default(0);
$table->string('kategori_id', 26)->index();
$table->timestamps(); // created_at, updated_at otomatis
$table->timestamp('deleted_at')->nullable(); // soft delete manual
$table->string('created_by', 26)->nullable();
$table->string('updated_by', 26)->nullable();
$table->string('deleted_by', 26)->nullable();
// Indeks komposit untuk query umum
$table->index(['deleted_at', 'kategori_id'], 'idx_produk_aktif_kategori');
});
}
public function down(): void
{
Schema::dropIfExists('produk');
}
};
# Jalankan semua migration yang belum diterapkan
php artisan migrate
# Rollback migration terakhir (panggil method down())
php artisan migrate:rollback
# Rollback 3 batch migration terakhir
php artisan migrate:rollback --step=3
# Lihat status semua migration
php artisan migrate:status
# Buat file migration baru
php artisan make:migration tambah_kolom_deskripsi_ke_produk --table=produk
Flyway — Database-Agnostic untuk Proyek Java/Enterprise
Flyway adalah migration tool berbasis SQL murni yang dapat digunakan dengan bahasa pemrograman apapun. Cocok untuk tim yang ingin kontrol penuh atas SQL yang dieksekusi, atau untuk proyek yang menggunakan multiple bahasa pemrograman.
-- db/migration/V1__create_produk_table.sql
CREATE TABLE produk (
id VARCHAR(26) PRIMARY KEY,
kode VARCHAR(50) NOT NULL UNIQUE,
nama VARCHAR(255) NOT NULL,
harga DECIMAL(15,2) NOT NULL DEFAULT 0,
stok INTEGER NOT NULL DEFAULT 0,
kategori_id VARCHAR(26) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE NULL,
created_by VARCHAR(26) NULL,
updated_by VARCHAR(26) NULL,
deleted_by VARCHAR(26) NULL
);
CREATE INDEX idx_produk_kategori ON produk(kategori_id);
CREATE INDEX idx_produk_deleted_at ON produk(deleted_at);
CREATE INDEX idx_produk_aktif ON produk(deleted_at, kategori_id)
WHERE deleted_at IS NULL;
-- db/migration/V2__add_deskripsi_to_produk.sql
ALTER TABLE produk
ADD COLUMN deskripsi TEXT NULL,
ADD COLUMN gambar_url VARCHAR(500) NULL;
-- db/migration/V3__create_kategori_table.sql
CREATE TABLE kategori (
id VARCHAR(26) PRIMARY KEY,
nama VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE NULL
);
golang-migrate — Ekosistem Go
Untuk proyek Go, golang-migrate adalah pilihan standar industri. Tool ini mendukung berbagai database dan dapat digunakan baik via CLI maupun embedded dalam kode Go.
-- migrations/000001_create_produk.up.sql
CREATE TABLE IF NOT EXISTS produk (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
kode TEXT NOT NULL,
nama TEXT NOT NULL,
harga NUMERIC(15,2) NOT NULL DEFAULT 0,
stok INTEGER NOT NULL DEFAULT 0,
kategori_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
created_by TEXT NULL,
updated_by TEXT NULL
);
CREATE UNIQUE INDEX idx_produk_kode ON produk(kode) WHERE deleted_at IS NULL;
CREATE INDEX idx_produk_kategori ON produk(kategori_id);
-- migrations/000001_create_produk.down.sql
DROP TABLE IF EXISTS produk;
// Menggunakan golang-migrate dalam kode Go
package main
import (
"database/sql"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
)
func runMigrations(db *sql.DB) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres",
driver,
)
if err != nil {
return err
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
log.Println("Migration berhasil diterapkan")
return nil
}
Perbandingan Migration Tools
| Tool | Ekosistem | Format Migration | Rollback | Keunggulan |
|---|---|---|---|---|
| Prisma Migrate | TypeScript/Node.js | Schema deklaratif → SQL | Otomatis (down migration) | Type-safe, auto-generate SQL |
| Laravel Artisan | PHP/Laravel | PHP fluent builder | Method down() manual | Integrasi Eloquent, mudah dibaca |
| Flyway | Java/Any | SQL murni | Undo migration (versi pro) | Control penuh, database-agnostic |
| golang-migrate | Go | SQL murni (.up/.down) | File .down.sql eksplisit | Ringan, embeddable |
Seed Data untuk Development
Seed data adalah data awal yang diisi ke database untuk tujuan pengembangan dan testing. Seed harus deterministik — menjalankan seed dua kali tidak boleh menghasilkan data duplikat yang memecahkan constraint.
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// Gunakan upsert agar seed idempoten
const kategoriMakanan = await prisma.kategori.upsert({
where: { slug: 'makanan' },
update: {},
create: {
id: 'cat_01h8x1y2z3a4b5c6d7e8f9g0h1',
nama: 'Makanan & Minuman',
slug: 'makanan',
},
})
const kategoriElektronik = await prisma.kategori.upsert({
where: { slug: 'elektronik' },
update: {},
create: {
id: 'cat_01h8x1y2z3a4b5c6d7e8f9g0h2',
nama: 'Elektronik',
slug: 'elektronik',
},
})
// Seed produk dengan data realistis Indonesia
const produkList = [
{
id: 'prd_01h8x1y2z3a4b5c6d7e8f9g0p1',
kode: 'MKN-001',
nama: 'Nasi Goreng Spesial',
harga: 25000,
stok: 100,
kategoriId: kategoriMakanan.id,
},
{
id: 'prd_01h8x1y2z3a4b5c6d7e8f9g0p2',
kode: 'ELK-001',
nama: 'Charger USB-C 65W',
harga: 185000,
stok: 50,
kategoriId: kategoriElektronik.id,
},
]
for (const produk of produkList) {
await prisma.produk.upsert({
where: { kode: produk.kode },
update: {},
create: produk,
})
}
console.log('Seed selesai:', {
kategori: 2,
produk: produkList.length,
})
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())
Rumus. Urutan commit yang benar: (1) tulis migration, (2) jalankan di lokal, (3) tulis seed jika perlu, (4) commit semua file migration bersama kode fitur yang membutuhkan kolom baru. Jangan pernah commit kode yang membutuhkan kolom baru tanpa menyertakan migration-nya dalam PR yang sama.
Soft Delete Implementation
Soft delete adalah salah satu rumus paling penting dalam pembangunan aplikasi bisnis yang serius. Alih-alih menghapus baris dari database secara permanen, soft delete hanya menandai baris sebagai “terhapus” dengan mengisi kolom deleted_at dengan timestamp saat penghapusan terjadi. Data asli tetap ada, bisa dipulihkan kapan saja, dan bisa diaudit — siapa menghapus apa, kapan. Ini bukan fitur mewah; ini adalah perlindungan bisnis minimum yang wajib ada di setiap tabel yang menyimpan data penting.
Rumus Wajib. Setiap tabel data bisnis HARUS memiliki kolom
deleted_at TIMESTAMP NULL. Baris aktif:deleted_at IS NULL. Baris terhapus:deleted_at IS NOT NULL. Tidak ada penghapusan fisik (hard delete) kecuali data GDPR atau data sampah teknis.
Skema Kolom Soft Delete
Implementasi minimum membutuhkan satu kolom. Implementasi lengkap membutuhkan tiga kolom untuk audit trail yang sempurna:
-- Kolom soft delete standar yang wajib ada di setiap tabel bisnis
ALTER TABLE produk
ADD COLUMN deleted_at TIMESTAMP WITH TIME ZONE NULL,
ADD COLUMN deleted_by VARCHAR(26) NULL;
-- Indeks partial: hanya baris aktif yang diindeks
-- Ini sangat penting untuk performa query aktif di tabel besar
CREATE INDEX idx_produk_aktif ON produk (kategori_id, created_at DESC)
WHERE deleted_at IS NULL;
-- Indeks untuk query data terhapus (admin panel, restore)
CREATE INDEX idx_produk_terhapus ON produk (deleted_at DESC)
WHERE deleted_at IS NOT NULL;
Global Query Scope — Filter Otomatis di Setiap Query
Tantangan terbesar soft delete bukan menandai data sebagai terhapus, tetapi memastikan SEMUA query di seluruh aplikasi secara otomatis mengecualikan data terhapus. Inilah yang disebut global query scope — filter yang berjalan otomatis di setiap operasi database tanpa perlu menulis WHERE deleted_at IS NULL secara manual di setiap query.
Global Scope di Prisma (Middleware)
// lib/prisma.ts — Prisma dengan soft delete middleware
import { PrismaClient } from '@prisma/client'
const prismaBase = new PrismaClient()
// Middleware untuk filter otomatis deleted_at IS NULL
prismaBase.$use(async (params, next) => {
// Model yang menggunakan soft delete
const softDeleteModels = ['Produk', 'Kategori', 'Pelanggan', 'Transaksi']
if (softDeleteModels.includes(params.model ?? '')) {
// Untuk operasi findMany, findFirst, findUnique, count
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args = params.args ?? {}
params.args.where = params.args.where ?? {}
// Jangan tambahkan filter jika query meminta data terhapus
if (!params.args.where.deletedAt) {
params.args.where.deletedAt = null
}
}
// Untuk count query
if (params.action === 'count') {
params.args = params.args ?? {}
params.args.where = params.args.where ?? {}
if (!params.args.where.deletedAt) {
params.args.where.deletedAt = null
}
}
// Ubah delete menjadi soft delete
if (params.action === 'delete') {
params.action = 'update'
params.args.data = {
deletedAt: new Date(),
}
}
// Ubah deleteMany menjadi updateMany dengan soft delete
if (params.action === 'deleteMany') {
params.action = 'updateMany'
params.args.data = {
deletedAt: new Date(),
}
}
}
return next(params)
})
export const prisma = prismaBase
// Helper untuk query yang perlu melihat data terhapus (admin)
export const prismaWithDeleted = prismaBase
Global Scope di Laravel (Eloquent)
<?php
// app/Models/Produk.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Produk extends Model
{
// Trait SoftDeletes otomatis menambahkan global scope
// dan mengubah delete() menjadi mengisi deleted_at
use SoftDeletes;
protected $table = 'produk';
protected $primaryKey = 'id';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'kode', 'nama', 'harga', 'stok',
'kategori_id', 'created_by', 'updated_by',
];
protected $casts = [
'harga' => 'decimal:2',
'stok' => 'integer',
'deleted_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// Eloquent SoftDeletes sudah menyediakan:
// Produk::all() → hanya yang deleted_at IS NULL
// Produk::withTrashed() → semua termasuk yang terhapus
// Produk::onlyTrashed() → hanya yang terhapus
// $produk->delete() → isi deleted_at = now()
// $produk->restore() → set deleted_at = null
// $produk->forceDelete() → hapus fisik dari database
}
Endpoint REST untuk Restore dan Force Delete
Setiap fitur soft delete wajib disertai endpoint untuk memulihkan data yang terhapus. Endpoint ini biasanya hanya bisa diakses oleh admin atau supervisor, dan harus dicatat dalam audit trail.
// routes/api/produk.ts — Endpoint soft delete + restore
import { Router } from 'express'
import { prisma } from '../../lib/prisma'
import { requireRole } from '../../middleware/auth'
const router = Router()
// DELETE /api/produk/:id — Soft delete (hapus ke tempat sampah)
router.delete('/:id', async (req, res) => {
const { id } = req.params
const userId = req.user.id
const produk = await prisma.produk.findFirst({
where: { id, deletedAt: null },
})
if (!produk) {
return res.status(404).json({ message: 'Produk tidak ditemukan' })
}
await prisma.produk.update({
where: { id },
data: {
deletedAt: new Date(),
deletedBy: userId,
},
})
// Catat di audit trail
await prisma.auditLog.create({
data: {
entityType: 'produk',
entityId: id,
action: 'DELETE',
performedBy: userId,
oldData: JSON.stringify(produk),
newData: null,
},
})
return res.json({ message: 'Produk berhasil dihapus ke tempat sampah' })
})
// POST /api/produk/:id/restore — Pulihkan data yang terhapus
router.post('/:id/restore', requireRole('admin'), async (req, res) => {
const { id } = req.params
const userId = req.user.id
// Cari record yang sudah terhapus (butuh bypass global scope)
// Gunakan prismaWithDeleted atau where eksplisit deletedAt: { not: null }
const produk = await prisma.produk.findFirst({
where: {
id,
deletedAt: { not: null }, // bypass global scope
},
})
if (!produk) {
return res.status(404).json({ message: 'Data terhapus tidak ditemukan' })
}
const restored = await prisma.produk.update({
where: { id },
data: {
deletedAt: null,
deletedBy: null,
},
})
await prisma.auditLog.create({
data: {
entityType: 'produk',
entityId: id,
action: 'RESTORE',
performedBy: userId,
oldData: JSON.stringify(produk),
newData: JSON.stringify(restored),
},
})
return res.json({
message: 'Produk berhasil dipulihkan',
data: restored,
})
})
// DELETE /api/produk/:id/force — Hard delete (hapus permanen)
router.delete(
'/:id/force',
requireRole('superadmin'),
async (req, res) => {
const { id } = req.params
// Pastikan record sudah soft-deleted dulu sebelum bisa force delete
const produk = await prisma.produk.findFirst({
where: { id, deletedAt: { not: null } },
})
if (!produk) {
return res.status(404).json({
message: 'Data harus dihapus ke tempat sampah terlebih dahulu',
})
}
await prisma.$executeRaw`
DELETE FROM produk WHERE id = ${id}
`
return res.json({ message: 'Produk dihapus permanen' })
}
)
export default router
Soft Delete pada Relasi Antar Tabel
Soft delete menjadi kompleks ketika melibatkan relasi. Misalnya: Kategori dihapus, tapi Produk yang berelasi masih ada (soft-deleted). Aturannya adalah: penghapusan induk tidak secara otomatis menghapus anak — ini harus diputuskan secara eksplisit dalam business logic.
// Cascade soft delete: hapus kategori + semua produknya
async function softDeleteKategoriDenganProduk(
kategoriId: string,
deletedBy: string
) {
const now = new Date()
// Gunakan transaction agar konsisten
await prisma.$transaction(async (tx) => {
// 1. Soft delete kategori
await tx.kategori.update({
where: { id: kategoriId },
data: { deletedAt: now, deletedBy },
})
// 2. Soft delete semua produk dalam kategori ini
await tx.produk.updateMany({
where: {
kategoriId,
deletedAt: null, // hanya yang belum terhapus
},
data: { deletedAt: now, deletedBy },
})
})
}
// Restore kategori TIDAK otomatis restore produk
// (produk perlu di-restore secara terpisah — explicit is better)
async function restoreKategori(kategoriId: string) {
await prisma.kategori.update({
where: { id: kategoriId },
data: { deletedAt: null, deletedBy: null },
})
// Produk dibiarkan dalam kondisi terhapus
// Admin perlu restore produk secara manual jika diperlukan
}
UI Tempat Sampah (Trash View)
Soft delete tidak berguna tanpa UI yang memungkinkan pengguna melihat dan memulihkan data terhapus. Standar UI yang direkomendasikan adalah halaman “Tempat Sampah” yang dapat diakses dari menu admin, menampilkan daftar data terhapus dengan tombol Pulihkan dan Hapus Permanen.
// Komponen React untuk halaman tempat sampah
// GET /api/produk?trash=true — query data terhapus
// Backend: endpoint dengan parameter trash
router.get('/', async (req, res) => {
const { trash, page = 1, limit = 20 } = req.query
const skip = (Number(page) - 1) * Number(limit)
const where = trash === 'true'
? { deletedAt: { not: null } } // hanya yang terhapus
: { deletedAt: null } // hanya yang aktif (default)
const [items, total] = await Promise.all([
prisma.produk.findMany({
where,
skip,
take: Number(limit),
orderBy: trash === 'true'
? { deletedAt: 'desc' }
: { createdAt: 'desc' },
}),
prisma.produk.count({ where }),
])
return res.json({
data: items,
meta: { total, page: Number(page), limit: Number(limit) },
})
})
Awas. Jangan lupa tambahkan
WHERE deleted_at IS NULLdi setiap query yang ditulis secara manual (raw SQL). Global scope hanya bekerja jika Anda menggunakan metode ORM — bukan raw query string. Periksa setiapprisma.$queryRawatauDB::statement()yang ada di kode Anda.
Audit Trail & History
Dalam setiap aplikasi bisnis yang digunakan oleh banyak pengguna, pertanyaan yang paling sering muncul ketika ada masalah adalah: “Siapa yang mengubah ini? Kapan? Apa yang diubah?” Audit trail adalah sistem pencatatan yang menjawab ketiga pertanyaan tersebut secara otomatis untuk setiap operasi tulis di database. Ini bukan fitur tambahan — ini adalah kebutuhan kepatuhan, keamanan, dan debugging yang wajib ada di setiap aplikasi yang menyimpan data finansial, data pelanggan, atau data operasional penting.
Empat Dimensi Audit Trail
Sistem audit trail yang baik mencatat empat hal: siapa (identitas pengguna yang melakukan aksi), kapan (timestamp yang akurat dengan timezone), apa yang dilakukan (CREATE, UPDATE, DELETE, RESTORE), dan apa yang berubah (nilai lama versus nilai baru). Tanpa salah satu dari empat dimensi ini, audit trail tidak dapat digunakan untuk investigasi yang serius.
Kolom Audit di Setiap Tabel
Lapisan pertama audit trail adalah kolom standar yang ada di setiap tabel bisnis. Kolom ini tidak membutuhkan query tambahan — informasinya tersimpan langsung bersama data.
-- Kolom audit standar yang wajib ada di setiap tabel bisnis
-- Tambahkan ke setiap CREATE TABLE atau via ALTER TABLE
-- Tabel produk dengan kolom audit lengkap
CREATE TABLE produk (
id VARCHAR(26) PRIMARY KEY,
kode VARCHAR(50) NOT NULL UNIQUE,
nama VARCHAR(255) NOT NULL,
harga DECIMAL(15,2) NOT NULL,
stok INTEGER NOT NULL DEFAULT 0,
kategori_id VARCHAR(26) NOT NULL,
-- Audit columns
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE NULL,
created_by VARCHAR(26) NULL, -- user ID yang membuat
updated_by VARCHAR(26) NULL, -- user ID yang terakhir mengubah
deleted_by VARCHAR(26) NULL -- user ID yang menghapus
);
-- Trigger PostgreSQL untuk auto-update updated_at
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at_produk
BEFORE UPDATE ON produk
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
Tabel Audit Log Terpusat
Kolom audit di tabel bisnis hanya menyimpan kondisi terakhir. Untuk menyimpan riwayat lengkap setiap perubahan — termasuk nilai sebelum dan sesudah perubahan — dibutuhkan tabel audit log terpisah.
-- Tabel audit_log: pusat pencatatan semua perubahan data
CREATE TABLE audit_log (
id VARCHAR(26) PRIMARY KEY DEFAULT gen_random_uuid()::text,
entity_type VARCHAR(100) NOT NULL, -- nama tabel: 'produk', 'pelanggan'
entity_id VARCHAR(26) NOT NULL, -- id record yang berubah
action VARCHAR(20) NOT NULL, -- CREATE, UPDATE, DELETE, RESTORE
old_data JSONB NULL, -- data sebelum perubahan (null untuk CREATE)
new_data JSONB NULL, -- data sesudah perubahan (null untuk DELETE)
changed_fields TEXT[] NULL, -- daftar field yang berubah
ip_address INET NULL,
user_agent TEXT NULL,
performed_by VARCHAR(26) NOT NULL, -- user ID
performed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Index untuk query audit
CONSTRAINT chk_action CHECK (
action IN ('CREATE', 'UPDATE', 'DELETE', 'RESTORE', 'LOGIN', 'LOGOUT')
)
);
CREATE INDEX idx_audit_entity ON audit_log (entity_type, entity_id, performed_at DESC);
CREATE INDEX idx_audit_user ON audit_log (performed_by, performed_at DESC);
CREATE INDEX idx_audit_time ON audit_log (performed_at DESC);
Implementasi Audit Service di TypeScript
Praktik terbaik adalah membuat satu service terpusat yang bertanggung jawab untuk semua pencatatan audit. Setiap service lain memanggil audit service ini — bukan menulis langsung ke tabel audit_log.
// services/audit.service.ts
import { prisma } from '../lib/prisma'
export type AuditAction = 'CREATE' | 'UPDATE' | 'DELETE' | 'RESTORE'
interface AuditOptions {
entityType: string
entityId: string
action: AuditAction
oldData?: Record<string, unknown> | null
newData?: Record<string, unknown> | null
performedBy: string
ipAddress?: string
userAgent?: string
}
export class AuditService {
static async log(options: AuditOptions): Promise<void> {
const changedFields = AuditService.getChangedFields(
options.oldData,
options.newData
)
await prisma.auditLog.create({
data: {
entityType: options.entityType,
entityId: options.entityId,
action: options.action,
oldData: options.oldData ? JSON.stringify(options.oldData) : null,
newData: options.newData ? JSON.stringify(options.newData) : null,
changedFields,
ipAddress: options.ipAddress,
userAgent: options.userAgent,
performedBy: options.performedBy,
performedAt: new Date(),
},
})
}
// Hitung field mana yang berubah antara oldData dan newData
private static getChangedFields(
oldData?: Record<string, unknown> | null,
newData?: Record<string, unknown> | null
): string[] {
if (!oldData || !newData) return []
return Object.keys(newData).filter((key) => {
// Skip kolom audit itu sendiri
if (['updatedAt', 'updatedBy'].includes(key)) return false
return JSON.stringify(oldData[key]) !== JSON.stringify(newData[key])
})
}
// Ambil riwayat perubahan satu record
static async getHistory(entityType: string, entityId: string) {
return prisma.auditLog.findMany({
where: { entityType, entityId },
orderBy: { performedAt: 'desc' },
take: 50, // batasi 50 entri terakhir
})
}
// Ambil aktivitas user tertentu
static async getUserActivity(
userId: string,
options: { page: number; limit: number }
) {
const { page, limit } = options
return prisma.auditLog.findMany({
where: { performedBy: userId },
orderBy: { performedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
})
}
}
Integrasi Audit di CRUD Service
// services/produk.service.ts — integrasi audit trail
import { prisma } from '../lib/prisma'
import { AuditService } from './audit.service'
export class ProdukService {
static async create(
data: CreateProdukDto,
context: { userId: string; ip: string }
) {
const produk = await prisma.produk.create({
data: { ...data, createdBy: context.userId, updatedBy: context.userId },
})
// Catat audit: CREATE, tidak ada oldData
await AuditService.log({
entityType: 'produk',
entityId: produk.id,
action: 'CREATE',
oldData: null,
newData: produk as unknown as Record<string, unknown>,
performedBy: context.userId,
ipAddress: context.ip,
})
return produk
}
static async update(
id: string,
data: UpdateProdukDto,
context: { userId: string; ip: string }
) {
// Ambil data lama sebelum diubah
const oldProduk = await prisma.produk.findFirstOrThrow({
where: { id, deletedAt: null },
})
const updated = await prisma.produk.update({
where: { id },
data: { ...data, updatedBy: context.userId },
})
// Catat audit: UPDATE dengan perbandingan old vs new
await AuditService.log({
entityType: 'produk',
entityId: id,
action: 'UPDATE',
oldData: oldProduk as unknown as Record<string, unknown>,
newData: updated as unknown as Record<string, unknown>,
performedBy: context.userId,
ipAddress: context.ip,
})
return updated
}
}
Trigger Database vs Implementasi Aplikasi
| Pendekatan | Keunggulan | Kelemahan | Kapan Digunakan |
|---|---|---|---|
| Trigger Database | Tidak bisa di-bypass, bekerja untuk semua koneksi | Sulit dapat user context, logika tersebar di DB | Kebutuhan kepatuhan ketat (perbankan, medis) |
| Aplikasi (Service) | Punya user context, mudah ditest, bisa log request info | Bisa di-bypass jika ada query langsung ke DB | Mayoritas aplikasi bisnis — lebih fleksibel |
| ORM Hooks/Events | Dekat dengan ORM, tidak perlu tulis manual di tiap service | Susah dapat user context tanpa dependency injection | Framework dengan event system matang (Laravel) |
Trigger PostgreSQL untuk Audit Otomatis
-- Trigger audit di level database (tidak bisa di-bypass)
-- Berguna sebagai layer kedua keamanan
CREATE TABLE produk_audit (
LIKE audit_log INCLUDING ALL,
produk_id VARCHAR(26) NOT NULL
);
CREATE OR REPLACE FUNCTION audit_produk_changes()
RETURNS TRIGGER AS $$
DECLARE
v_old_data JSONB;
v_new_data JSONB;
v_action TEXT;
BEGIN
IF (TG_OP = 'DELETE') THEN
v_old_data = row_to_json(OLD)::JSONB;
v_new_data = NULL;
v_action = 'DELETE';
ELSIF (TG_OP = 'UPDATE') THEN
v_old_data = row_to_json(OLD)::JSONB;
v_new_data = row_to_json(NEW)::JSONB;
v_action = 'UPDATE';
ELSIF (TG_OP = 'INSERT') THEN
v_old_data = NULL;
v_new_data = row_to_json(NEW)::JSONB;
v_action = 'CREATE';
END IF;
INSERT INTO produk_audit (
entity_type, entity_id, action,
old_data, new_data, performed_by, performed_at, produk_id
) VALUES (
'produk',
COALESCE(NEW.id, OLD.id),
v_action,
v_old_data,
v_new_data,
COALESCE(NEW.updated_by, OLD.updated_by, 'system'),
NOW(),
COALESCE(NEW.id, OLD.id)
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_produk
AFTER INSERT OR UPDATE OR DELETE ON produk
FOR EACH ROW EXECUTE FUNCTION audit_produk_changes();
Tips. Tabel audit_log cepat besar. Buat job harian yang memindahkan data audit yang lebih dari 90 hari ke tabel arsip (
audit_log_archive). Atau gunakan partitioning PostgreSQL berdasarkan bulan:PARTITION BY RANGE (performed_at). Di aplikasi UMKM, 1 juta baris audit adalah ukuran normal dalam 6 bulan operasi.
No Foreign Key — Alternative
Foreign key constraint adalah fitur database yang secara otomatis memastikan baris di tabel anak selalu memiliki baris induk yang valid. Secara teori, ini terdengar sempurna. Dalam praktik pembangunan aplikasi modern — terutama yang menggunakan soft delete, multi-tenant architecture, atau sistem yang perlu scale — foreign key constraint di level database justru menjadi hambatan serius. Rumus yang kami rekomendasikan adalah: tidak menggunakan foreign key constraint di database, namun mengimplementasikan referential integrity di application layer.
Rumus Wajib. Jangan buat
FOREIGN KEYconstraint di database. Gantikan dengan: (1) index manual pada kolom referensi, (2) validasi di application layer sebelum setiap operasi tulis, (3) batch validation job yang berjalan periodik untuk mendeteksi orphan data.
Alasan Teknis Tidak Menggunakan Foreign Key
Ada lima alasan konkret mengapa foreign key constraint lebih banyak merugikan daripada menguntungkan dalam konteks aplikasi bisnis modern:
1. Konflik dengan Soft Delete. Jika tabel Kategori memiliki FK constraint ke tabel Produk, maka Anda tidak bisa soft-delete Kategori selama masih ada Produk aktif yang mereferensikannya. Database akan melempar error karena FK constraint melihat data secara fisik — ia tidak mengerti konsep “terhapus logis”. Anda terpaksa hard-delete Produk dulu, atau menonaktifkan constraint sementara — keduanya berbahaya.
2. Performa INSERT/UPDATE yang Lebih Lambat. Setiap kali Anda INSERT ke tabel anak, database harus mengunci baris induk untuk memverifikasi FK constraint. Dalam operasi bulk import (misalnya mengimpor 50.000 baris dari Excel), setiap baris menghasilkan lock lookup ke tabel induk. Ini bisa memperlambat operasi 3–5x dibanding tanpa FK constraint.
3. Kompleksitas Urutan Operasi. Dengan FK constraint, urutan INSERT dan DELETE menjadi penting. Anda harus INSERT induk dulu, baru anak. Anda harus DELETE anak dulu, baru induk. Dalam sistem yang kompleks dengan banyak relasi, urutan ini menjadi sangat sulit dikelola, terutama saat rollback atau data migration.
4. Masalah Multi-Tenant dan Sharding. Dalam arsitektur multi-tenant di mana data tenant yang berbeda mungkin berada di schema atau database yang berbeda, FK constraint antar schema tidak mungkin ditegakkan oleh database. Referential integrity harus dikelola oleh aplikasi bagaimanapun juga.
5. Migrasi Data Lebih Mudah. Saat melakukan data migration besar — misalnya memindahkan data dari sistem lama ke sistem baru — FK constraint sering menghalangi proses karena urutan loading data yang tidak sempurna. Tanpa FK, Anda bisa load data dalam urutan apapun dan validasi belakangan.
Alternatif: Index Manual pada Kolom Referensi
Salah satu fungsi FK adalah memastikan query JOIN berjalan cepat karena FK column otomatis diindeks. Tanpa FK, Anda harus membuat index secara manual — yang sebenarnya memberikan kontrol lebih karena Anda bisa membuat partial index yang lebih efisien.
-- TANPA foreign key constraint, DENGAN index manual yang optimal
CREATE TABLE produk (
id VARCHAR(26) PRIMARY KEY,
kode VARCHAR(50) NOT NULL,
nama VARCHAR(255) NOT NULL,
harga DECIMAL(15,2) NOT NULL,
kategori_id VARCHAR(26) NOT NULL, -- referensi ke tabel kategori, TANPA FK
tenant_id VARCHAR(26) NOT NULL, -- referensi ke tabel tenant, TANPA FK
deleted_at TIMESTAMP WITH TIME ZONE NULL
);
-- Index standar untuk JOIN dengan kategori
CREATE INDEX idx_produk_kategori_id ON produk (kategori_id);
-- Index komposit untuk query yang umum (filter aktif + kategori)
CREATE INDEX idx_produk_aktif_kategori ON produk (kategori_id, created_at DESC)
WHERE deleted_at IS NULL;
-- Index untuk multi-tenant isolation
CREATE INDEX idx_produk_tenant ON produk (tenant_id, deleted_at, created_at DESC);
-- Index untuk bulk validation job: cari orphan data
CREATE INDEX idx_produk_kategori_null_check ON produk (kategori_id)
WHERE deleted_at IS NULL;
Validasi di Application Layer
Tanpa FK constraint, tanggung jawab validasi referensi berpindah ke application layer. Ini harus dilakukan secara konsisten di setiap operasi tulis yang melibatkan kolom referensi.
// services/produk.service.ts — validasi referensi tanpa FK
export class ProdukService {
static async create(
data: CreateProdukDto,
context: { userId: string; tenantId: string }
) {
// WAJIB: Validasi keberadaan kategori sebelum INSERT
const kategori = await prisma.kategori.findFirst({
where: {
id: data.kategoriId,
tenantId: context.tenantId, // pastikan milik tenant yang sama
deletedAt: null, // pastikan tidak terhapus
},
})
if (!kategori) {
throw new ValidationError(
'kategori_id',
'Kategori tidak ditemukan atau sudah dihapus'
)
}
// INSERT setelah validasi lolos
return prisma.produk.create({
data: {
...data,
tenantId: context.tenantId,
createdBy: context.userId,
updatedBy: context.userId,
},
})
}
static async update(
id: string,
data: UpdateProdukDto,
context: { userId: string; tenantId: string }
) {
// Jika kategoriId diubah, validasi kategori baru
if (data.kategoriId) {
const kategori = await prisma.kategori.findFirst({
where: {
id: data.kategoriId,
tenantId: context.tenantId,
deletedAt: null,
},
})
if (!kategori) {
throw new ValidationError(
'kategori_id',
'Kategori tujuan tidak valid'
)
}
}
return prisma.produk.update({
where: { id },
data: { ...data, updatedBy: context.userId },
})
}
}
Batch Validation Job — Deteksi Orphan Data
Meskipun validasi dilakukan di setiap operasi tulis, tetap ada kemungkinan orphan data muncul — misalnya karena bug, import data langsung via SQL, atau race condition. Batch validation job berjalan secara periodik (misalnya setiap malam) untuk mendeteksi dan melaporkan inkonsistensi ini.
-- Query untuk mendeteksi orphan data (produk dengan kategori yang sudah terhapus)
-- Dijalankan oleh batch job setiap malam
SELECT
p.id,
p.kode,
p.nama,
p.kategori_id,
'kategori_terhapus' AS masalah,
k.deleted_at AS kategori_deleted_at
FROM produk p
LEFT JOIN kategori k ON p.kategori_id = k.id
WHERE
p.deleted_at IS NULL -- produk masih aktif
AND (
k.id IS NULL -- kategori tidak ada sama sekali
OR k.deleted_at IS NOT NULL -- kategori sudah soft-deleted
)
ORDER BY p.created_at DESC;
-- Query: produk dengan tenant yang tidak valid
SELECT
p.id,
p.kode,
p.tenant_id,
'tenant_tidak_valid' AS masalah
FROM produk p
LEFT JOIN tenant t ON p.tenant_id = t.id
WHERE
p.deleted_at IS NULL
AND (t.id IS NULL OR t.deleted_at IS NOT NULL);
// jobs/integrity-check.job.ts — batch validation setiap malam
import cron from 'node-cron'
import { prisma } from '../lib/prisma'
import { NotificationService } from '../services/notification.service'
// Jalankan setiap hari pukul 02:00 pagi
cron.schedule('0 2 * * *', async () => {
console.log('[IntegrityCheck] Memulai validasi referential integrity...')
// Cari produk dengan kategori tidak valid
const orphanProduk = await prisma.$queryRaw<{ id: string; kode: string }[]>`
SELECT p.id, p.kode
FROM produk p
LEFT JOIN kategori k ON p.kategori_id = k.id
WHERE p.deleted_at IS NULL
AND (k.id IS NULL OR k.deleted_at IS NOT NULL)
LIMIT 1000
`
if (orphanProduk.length > 0) {
console.warn(
`[IntegrityCheck] Ditemukan ${orphanProduk.length} produk dengan kategori tidak valid`
)
// Simpan laporan ke tabel integrity_issues
await prisma.integrityIssue.createMany({
data: orphanProduk.map((p) => ({
entityType: 'produk',
entityId: p.id,
issue: 'invalid_kategori_id',
detectedAt: new Date(),
resolvedAt: null,
})),
skipDuplicates: true,
})
// Kirim notifikasi ke tim teknis
await NotificationService.sendToAdmins({
title: 'Integrity Check Alert',
body: `Ditemukan ${orphanProduk.length} produk dengan referensi kategori tidak valid`,
severity: 'warning',
})
} else {
console.log('[IntegrityCheck] Semua referensi valid.')
}
})
Perbandingan: FK Constraint vs Application-Level Integrity
| Aspek | Foreign Key Constraint | Application-Level |
|---|---|---|
| Enforcement | Database otomatis | Developer wajib menulis manual |
| Soft Delete | Konflik langsung | Tidak ada konflik |
| Performa bulk INSERT | Lambat (lock lookup) | Cepat |
| Multi-tenant/sharding | Tidak mendukung cross-schema | Fleksibel sepenuhnya |
| Data migration | Sulit, harus urut | Mudah, validasi belakangan |
| Risiko orphan data | Nol (jika tidak ada bypass) | Ada — perlu batch validation job |
| Cocok untuk | Sistem sederhana, data statis | SaaS, multi-tenant, soft delete |
Awas. Memilih tidak menggunakan FK bukan berarti boleh malas validasi. Setiap operasi INSERT atau UPDATE yang melibatkan kolom referensi WAJIB melakukan validasi keberadaan record induk di application layer. FK constraint menghilangkan keharusan ini dari database, bukan dari sistem Anda secara keseluruhan.
Query Optimization
Aplikasi yang berjalan cepat saat masih 100 pengguna bisa menjadi sangat lambat ketika sudah 10.000 pengguna dan database berisi jutaan baris. Query yang tidak dioptimasi adalah penyebab nomor satu kinerja buruk pada aplikasi database-driven. Bab ini membahas cara membaca rencana eksekusi query, strategi indexing yang efektif, mengatasi masalah N+1 yang tersembunyi, dan memilih strategi pagination yang tepat untuk skala besar.
EXPLAIN ANALYZE — Membaca Rencana Eksekusi Query
Sebelum mengoptimasi, Anda harus memahami apa yang dilakukan database saat menjalankan query Anda. EXPLAIN ANALYZE di PostgreSQL adalah alat untuk ini — ia menunjukkan rencana eksekusi (execution plan) yang dipilih query planner beserta waktu aktual yang dihabiskan di setiap langkah.
-- Analisis query yang lambat
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT
p.id,
p.kode,
p.nama,
p.harga,
k.nama AS kategori_nama
FROM produk p
LEFT JOIN kategori k ON p.kategori_id = k.id
WHERE
p.deleted_at IS NULL
AND p.kategori_id = 'cat_01h8x1y2z3a4b5c6d7e8f9g0h1'
AND p.harga BETWEEN 10000 AND 500000
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;
-- Output EXPLAIN yang baik (ada index):
-- Index Scan using idx_produk_aktif_kategori on produk
-- (cost=0.43..8.45 rows=20 width=86)
-- (actual time=0.023..0.156 rows=20 loops=1)
-- Index Cond: ((deleted_at IS NULL) AND (kategori_id = '...'))
-- Filter: ((harga >= 10000) AND (harga <= 500000))
-- Planning Time: 0.312 ms
-- Execution Time: 0.234 ms <-- CEPAT
-- Output EXPLAIN yang buruk (Sequential Scan):
-- Seq Scan on produk
-- (cost=0.00..45231.00 rows=1234 width=86)
-- (actual time=23.456..2341.234 rows=20 loops=1)
-- Filter: ((deleted_at IS NULL) AND ...)
-- Rows Removed by Filter: 498234
-- Planning Time: 1.234 ms
-- Execution Time: 2341.456 ms <-- SANGAT LAMBAT
Kata kunci yang perlu diperhatikan dalam output EXPLAIN: Seq Scan pada tabel besar adalah tanda bahaya — query membaca semua baris. Index Scan atau Bitmap Index Scan adalah yang diinginkan. Hash Join lebih cepat daripada Nested Loop untuk JOIN dengan tabel besar. Rows Removed by Filter yang sangat tinggi menandakan indeks yang salah atau tidak ada.
Strategi Indeks yang Efektif
Membuat indeks sembarangan tidak membantu dan justru memperlambat operasi INSERT/UPDATE karena setiap indeks perlu diperbarui. Strategi indexing yang baik dimulai dari mengidentifikasi query yang paling sering dijalankan, lalu membuat indeks yang tepat.
-- 1. Partial Index: hanya indeks baris yang relevan
-- Jauh lebih kecil dan lebih cepat daripada indeks penuh
CREATE INDEX idx_produk_aktif_nama ON produk (nama)
WHERE deleted_at IS NULL;
-- 2. Composite Index: urutan kolom sangat penting
-- Ikuti urutan: (equality filter) → (range filter) → (order by)
CREATE INDEX idx_produk_cari ON produk (
tenant_id, -- equality: WHERE tenant_id = ?
kategori_id, -- equality: AND kategori_id = ?
created_at DESC -- order: ORDER BY created_at DESC
) WHERE deleted_at IS NULL;
-- 3. Index untuk full-text search (nama produk)
CREATE INDEX idx_produk_nama_fts ON produk
USING gin(to_tsvector('indonesian', nama));
-- Query dengan FTS
SELECT id, kode, nama, harga
FROM produk
WHERE
deleted_at IS NULL
AND to_tsvector('indonesian', nama) @@ to_tsquery('indonesian', 'charger & usb')
ORDER BY created_at DESC
LIMIT 20;
-- 4. Index untuk JSONB (metadata produk)
CREATE INDEX idx_produk_meta ON produk
USING gin(metadata);
-- Query JSONB yang terindeks
SELECT id, nama FROM produk
WHERE
deleted_at IS NULL
AND metadata @> '{"warna": "merah"}';
Masalah N+1 dan Solusinya
N+1 adalah bug performa paling umum dalam aplikasi yang menggunakan ORM. Ia terjadi ketika Anda mengambil daftar N record (1 query), kemudian untuk setiap record melakukan query tambahan untuk mengambil data terkait (N query) — total N+1 query untuk yang seharusnya cukup 1–2 query saja.
// BURUK: N+1 query problem
// Mengambil 20 produk = 1 query
// Untuk setiap produk, ambil kategori = 20 query
// Total: 21 query untuk tampilkan daftar 20 produk
const produkList = await prisma.produk.findMany({
where: { deletedAt: null },
take: 20,
})
// N+1: setiap iterasi menghasilkan query baru
for (const produk of produkList) {
const kategori = await prisma.kategori.findUnique({
where: { id: produk.kategoriId }, // QUERY ke database untuk setiap produk!
})
console.log(produk.nama, '->', kategori?.nama)
}
// ============================================================
// BAIK: Eager loading dengan include — hanya 2 query total
const produkList = await prisma.produk.findMany({
where: { deletedAt: null },
take: 20,
include: {
// Prisma akan JOIN atau subquery secara otomatis
// Tidak ada N+1
},
// Karena kita tidak punya relasi FK di Prisma schema
// gunakan select + JOIN manual atau DataLoader
})
// ALTERNATIF TERBAIK: DataLoader untuk batching request
import DataLoader from 'dataloader'
const kategoriLoader = new DataLoader<string, Kategori | null>(
async (ids) => {
// Satu query untuk SEMUA id yang diminta
const kategoriList = await prisma.kategori.findMany({
where: { id: { in: [...ids] }, deletedAt: null },
})
const map = new Map(kategoriList.map((k) => [k.id, k]))
return ids.map((id) => map.get(id) ?? null)
}
)
// Penggunaan: setiap panggilan .load() di-batch otomatis
for (const produk of produkList) {
const kategori = await kategoriLoader.load(produk.kategoriId)
// Hanya 1 query ke database untuk semua produk!
}
Pagination: Cursor vs Offset
Ada dua strategi pagination: offset-based (LIMIT x OFFSET y) dan cursor-based. Offset-based mudah diimplementasi namun menjadi sangat lambat saat offset besar. Cursor-based lebih kompleks namun performanya konsisten bahkan untuk halaman ke-1000.
-- OFFSET PAGINATION: mudah tapi lambat di halaman akhir
-- Untuk OFFSET 9980 di tabel dengan 10.000 baris,
-- database harus membaca 9980 baris dan membuangnya
SELECT id, kode, nama, harga, created_at
FROM produk
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 20 OFFSET 9980; -- Lambat! Database baca 9980+20 = 10.000 baris
-- CURSOR PAGINATION: cepat dan konsisten
-- Setelah halaman pertama, simpan cursor (nilai created_at + id terakhir)
-- Query berikutnya menggunakan cursor sebagai filter
-- Halaman pertama
SELECT id, kode, nama, harga, created_at
FROM produk
WHERE deleted_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 21; -- ambil 21, jika dapat 21 berarti masih ada halaman berikutnya
-- Halaman berikutnya: gunakan cursor dari item terakhir halaman sebelumnya
-- cursor = { created_at: '2024-01-15T10:30:00Z', id: 'prd_xxx' }
SELECT id, kode, nama, harga, created_at
FROM produk
WHERE
deleted_at IS NULL
AND (created_at, id) < ('2024-01-15T10:30:00Z', 'prd_xxx')
ORDER BY created_at DESC, id DESC
LIMIT 21;
// Implementasi cursor pagination di TypeScript
interface PaginationResult<T> {
data: T[]
nextCursor: string | null
hasMore: boolean
}
async function getProdukCursor(
tenantId: string,
cursor?: string,
limit = 20
): Promise<PaginationResult<Produk>> {
const take = limit + 1 // ambil satu lebih untuk cek hasMore
let cursorFilter = {}
if (cursor) {
// Decode cursor dari base64
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'))
cursorFilter = {
AND: [
{
OR: [
{ createdAt: { lt: new Date(decoded.createdAt) } },
{
createdAt: { equals: new Date(decoded.createdAt) },
id: { lt: decoded.id },
},
],
},
],
}
}
const items = await prisma.produk.findMany({
where: {
tenantId,
deletedAt: null,
...cursorFilter,
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take,
})
const hasMore = items.length > limit
const data = hasMore ? items.slice(0, limit) : items
const lastItem = data[data.length - 1]
const nextCursor = hasMore && lastItem
? Buffer.from(
JSON.stringify({
createdAt: lastItem.createdAt.toISOString(),
id: lastItem.id,
})
).toString('base64')
: null
return { data, nextCursor, hasMore }
}
Query Caching dengan Redis
Untuk query yang berat namun hasilnya tidak berubah sering — seperti laporan harian, daftar referensi, atau agregasi — caching di Redis adalah solusi yang sangat efektif. Data diambil dari database sekali, disimpan di Redis, dan untuk request berikutnya langsung dikembalikan dari cache tanpa query ke database.
// lib/cache.ts — wrapper Redis untuk query caching
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
export async function cachedQuery<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
// Cek cache dulu
const cached = await redis.get(key)
if (cached) {
return JSON.parse(cached) as T
}
// Cache miss: jalankan query ke database
const result = await fetcher()
// Simpan ke cache dengan TTL
await redis.setex(key, ttlSeconds, JSON.stringify(result))
return result
}
// Cache invalidation: hapus cache saat data berubah
export async function invalidateCache(pattern: string): Promise<void> {
const keys = await redis.keys(pattern)
if (keys.length > 0) {
await redis.del(...keys)
}
}
// Penggunaan:
// GET /api/kategori — data berubah jarang, cache 10 menit
const kategoriList = await cachedQuery(
`kategori:tenant:${tenantId}`,
600, // 10 menit
() => prisma.kategori.findMany({
where: { tenantId, deletedAt: null },
orderBy: { nama: 'asc' },
})
)
// Saat kategori ditambah/diubah/dihapus, invalidasi cache:
await invalidateCache(`kategori:tenant:${tenantId}`)
Ringkasan Strategi Optimasi
-- Teknik tambahan: SELECT hanya kolom yang dibutuhkan
-- BURUK: SELECT * membawa semua kolom termasuk yang besar (JSONB, TEXT panjang)
SELECT * FROM produk WHERE deleted_at IS NULL LIMIT 20;
-- BAIK: Hanya kolom yang ditampilkan di tabel UI
SELECT id, kode, nama, harga, stok, kategori_id, created_at
FROM produk
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 20;
-- SANGAT BAIK: Covering index — query terpenuhi dari index saja tanpa akses heap
-- Buat index yang mencakup semua kolom yang di-SELECT
CREATE INDEX idx_produk_list_covering ON produk (
created_at DESC, id
) INCLUDE (kode, nama, harga, stok, kategori_id)
WHERE deleted_at IS NULL;
-- Query di atas sekarang tidak perlu akses baris di tabel produk sama sekali
-- Semua data diambil langsung dari index (Index Only Scan)
Inti. Optimasi query adalah iterasi, bukan konfigurasi satu kali. Pasang slow query log (log_min_duration_statement = 100 di PostgreSQL) untuk merekam query yang membutuhkan lebih dari 100ms. Tinjau log ini setiap minggu dan optimalkan query termahal. Sebagian besar aplikasi memiliki 5–10 query yang bertanggung jawab atas 80% beban database — temukan dan optimalkan mereka.
Deployment & DevOps
Server Setup
Server adalah fondasi fisik tempat aplikasi Anda hidup. Pilihan infrastruktur yang tepat sejak awal menentukan bukan saja biaya operasional, melainkan juga kecepatan deploy, kemudahan scaling, dan ketenangan pikiran ketika trafik lonjak tiba-tiba di tengah malam. Bab ini memandu Anda dari pemilihan platform, hardening Linux, konfigurasi web server, hingga HTTPS siap produksi.
VPS, Cloud, atau Dedicated: Pilih Mana?
Tiga kategori infrastruktur utama memiliki karakteristik sangat berbeda. VPS (Virtual Private Server) menyewa satu irisan dari mesin fisik bersama; harga terjangkau, cocok untuk MVP dan startup tahap awal. Cloud seperti AWS, GCP, atau Azure menawarkan layanan terkelola — mulai dari database, load balancer, object storage, hingga auto-scaling — dengan model bayar per pemakaian. Dedicated server menyewa mesin fisik penuh; biaya tetap tinggi, performa maksimal, cocok untuk beban komputasi berat atau regulasi keamanan data ketat.
| Kriteria | VPS (Contoh: DigitalOcean, Vultr, IDCloudHost) | Cloud Managed (AWS/GCP/Azure) | Dedicated Server |
|---|---|---|---|
| Harga awal (Rp/bulan) | ~Rp 70.000 – Rp 500.000 | Rp 150.000 – jutaan (tergantung layanan) | Rp 1,5 juta – Rp 10 juta+ |
| Kemudahan setup | Sedang — perlu konfigurasi manual | Tinggi — banyak layanan terkelola | Rendah — full manual |
| Skalabilitas | Terbatas — perlu pindah plan atau tambah node | Elastis — auto-scaling tersedia | Tidak elastis — upgrade hardware manual |
| Kontrol penuh OS | Ya | Sebagian (EC2: ya; RDS: tidak) | Ya, penuh |
| Cocok untuk | MVP, startup < 10k pengguna | Produk berkembang, tim DevOps kecil | Fintech, kesehatan, data sensitif |
| Managed DB | Tidak (install sendiri) | Ya (RDS, Cloud SQL, Azure DB) | Tidak |
| Backup otomatis | Snapshot (berbayar) | Terintegrasi di layanan managed | Manual / RAID |
Inti. Untuk MVP Indonesia, mulai di VPS 2 vCPU / 4 GB RAM (~Rp 200.000/bulan di IDCloudHost atau DigitalOcean). Migrasi ke Cloud Managed ketika pengguna aktif tembus 5.000 atau ketika tim butuh managed database dan zero-downtime deploy.
Linux Server Hardening
Server baru yang baru saja diprovisioning adalah target empuk: port SSH terbuka untuk seluruh dunia, user root bisa login langsung, dan tidak ada firewall aktif. Langkah hardening berikut wajib dilakukan sebelum deploy kode apapun.
Buat user non-root terlebih dahulu, larang login root via SSH, aktifkan UFW, pasang fail2ban untuk blokir brute-force, dan pastikan sistem selalu ter-update. Urutan ini penting: jangan tutup akses sebelum memastikan user baru bisa masuk.
# ─── UBUNTU SERVER 22.04 — INITIAL HARDENING ───────────────────────────────
# 1. Update sistem
apt update && apt upgrade -y
# 2. Buat user deploy (ganti "deployer" sesuai kebutuhan)
adduser deployer
usermod -aG sudo deployer
# 3. Copy SSH key ke user baru (dari mesin lokal)
# Di mesin lokal: ssh-copy-id deployer@IP_SERVER
# 4. Konfigurasi SSH — larang root login & password auth
sed -i 's/^PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
echo "AllowUsers deployer" >> /etc/ssh/sshd_config
systemctl restart sshd
# 5. Aktifkan UFW firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw enable
# 6. Install fail2ban
apt install -y fail2ban
systemctl enable --now fail2ban
# 7. Nonaktifkan IPv6 jika tidak diperlukan (opsional)
echo "net.ipv6.conf.all.disable_ipv6 = 1" >> /etc/sysctl.conf
sysctl -p
# 8. Aktifkan unattended-upgrades
apt install -y unattended-upgrades
dpkg-reconfigure --priority=low unattended-upgrades
# 9. Install tool dasar
apt install -y curl wget git htop vim ufw net-tools build-essential
echo "=== Hardening selesai. Reconnect sebagai deployer ==="
Nginx sebagai Web Server & Reverse Proxy
Nginx digunakan sebagai reverse proxy di depan aplikasi Node.js, PHP-FPM, atau Python Gunicorn. Nginx menangani koneksi SSL, kompresi gzip, rate limiting, dan static file serving — sehingga proses aplikasi bisa fokus pada logika bisnis.
# /etc/nginx/sites-available/aplikasi.conf
server {
listen 80;
server_name aplikasi.co.id www.aplikasi.co.id;
# Redirect ke HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name aplikasi.co.id www.aplikasi.co.id;
# SSL — diisi otomatis oleh Certbot
ssl_certificate /etc/letsencrypt/live/aplikasi.co.id/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/aplikasi.co.id/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1000;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
# Reverse proxy ke aplikasi Node.js di port 3000
location /api/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
# Static files Next.js
location /_next/static/ {
alias /var/www/aplikasi/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Upload size
client_max_body_size 50M;
access_log /var/log/nginx/aplikasi_access.log;
error_log /var/log/nginx/aplikasi_error.log;
}
SSL/HTTPS dengan Let's Encrypt
Let's Encrypt menyediakan sertifikat SSL gratis dengan masa berlaku 90 hari dan perpanjangan otomatis. Certbot adalah tool resmi yang mengotomatisasi semua proses — dari verifikasi domain hingga penulisan konfigurasi Nginx.
# Install Certbot + plugin Nginx
apt install -y certbot python3-certbot-nginx
# Dapatkan & install sertifikat otomatis
certbot --nginx -d aplikasi.co.id -d www.aplikasi.co.id \
--non-interactive --agree-tos --email admin@aplikasi.co.id
# Uji perpanjangan otomatis (dry run)
certbot renew --dry-run
# Certbot otomatis membuat cron job di /etc/cron.d/certbot
# Verifikasi:
systemctl list-timers | grep certbot
# Aktifkan config Nginx
nginx -t && systemctl reload nginx
# Aktifkan site
ln -sf /etc/nginx/sites-available/aplikasi.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
Tips. Gunakan
SSL Labs(ssllabs.com/ssltest/) untuk memvalidasi konfigurasi SSL Anda. Target nilai A+. Pastikanssl_dhparamdi-generate denganopenssl dhparam -out /etc/ssl/certs/dhparam.pem 2048dan direferensi di Nginx config.
Process Manager: PM2 untuk Node.js
PM2 menjaga proses Node.js tetap berjalan saat server restart, mengelola multiple instance, dan menyediakan log management. Ini adalah pengganti nohup yang jauh lebih robust.
# Install PM2 global
npm install -g pm2
# Start aplikasi Next.js
cd /var/www/aplikasi
pm2 start npm --name "aplikasi" -- start
# Simpan daftar proses agar auto-start saat reboot
pm2 save
pm2 startup systemd
# Monitor real-time
pm2 monit
# Restart gracefully
pm2 reload aplikasi
# Logs
pm2 logs aplikasi --lines 100
Awas. Jangan jalankan Node.js langsung sebagai
root. Jalankan sebagai userdeployerdengan PM2 yang dikonfigurasi viapm2 startupagar berjalan sebagai systemd service milik user tersebut.
Docker & Containerization
Docker mengubah cara kita memikirkan lingkungan aplikasi. Alih-alih bergantung pada konfigurasi spesifik mesin, Docker mengemas aplikasi beserta seluruh dependensinya ke dalam image yang bisa berjalan identik di laptop developer, server staging, dan produksi. Filosofi ini — works on my machine menjadi works everywhere — adalah dasar dari deployment modern yang konsisten dan repeatable.
Konsep Dasar Docker
Docker bekerja dengan tiga entitas utama: Image adalah blueprint read-only yang berisi OS minimal, runtime, dependensi, dan kode aplikasi. Container adalah instance yang berjalan dari image — isolasi proses menggunakan Linux namespaces dan cgroups. Registry (Docker Hub, GHCR, ECR) adalah tempat menyimpan dan berbagi image. Setiap docker build menghasilkan image baru; setiap docker run membuat container segar dari image tersebut.
Dockerfile Best Practices: Multi-Stage Build
Multi-stage build memisahkan tahap build dan runtime dalam satu Dockerfile. Hasilnya: image produksi yang jauh lebih kecil karena tidak membawa compiler, dev dependencies, atau file build artifact yang tidak diperlukan di runtime. Ini penting untuk keamanan (attack surface lebih kecil) dan kecepatan deploy (image lebih kecil = transfer lebih cepat).
# ── Dockerfile — Next.js Production (Multi-Stage) ─────────────────────────────
# TAHAP 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && cp -R node_modules prod_node_modules
RUN npm ci
# TAHAP 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# TAHAP 3: Runtime (image final — sekecil mungkin)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Buat user non-root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy hanya yang diperlukan
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
Inti. Gunakan base image
alpineuntuk ukuran minimal. Image Node.js alpine sekitar 170 MB vs 900 MB versi full. Di produksi, perbedaan ini berarti pull image 5x lebih cepat saat scaling atau deploy.
Docker Compose untuk Development
Docker Compose mendefinisikan seluruh stack — aplikasi, database, Redis, Nginx — dalam satu file YAML. Developer cukup menjalankan docker compose up untuk mendapatkan lingkungan lokal yang identik dengan produksi. Tidak ada lagi "install MySQL dulu, buat database, set environment variable" yang berbeda-beda di setiap mesin.
# ── docker-compose.yml — Full Stack Development ───────────────────────────────
version: '3.9'
services:
# Aplikasi Next.js
app:
build:
context: .
dockerfile: Dockerfile.dev # Gunakan Dockerfile khusus dev (dengan hot reload)
ports:
- "3000:3000"
volumes:
- .:/app # Mount kode lokal untuk hot reload
- /app/node_modules # Cegah override node_modules container
- /app/.next # Cegah override .next build
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://pguser:pgpass@postgres:5432/appdb
- REDIS_URL=redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- appnet
# PostgreSQL Database
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: pguser
POSTGRES_PASSWORD: pgpass
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pguser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- appnet
# Redis Cache & Queue
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --requirepass redispass
volumes:
- redisdata:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "-a", "redispass", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
- appnet
# Nginx Reverse Proxy (opsional untuk dev, wajib untuk produksi)
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- appnet
volumes:
pgdata:
redisdata:
networks:
appnet:
driver: bridge
Docker Compose untuk Produksi
Untuk produksi, pisahkan file compose dan override menggunakan docker-compose.prod.yml. Hindari mount volume kode di produksi — gunakan image yang sudah di-build. Tambahkan restart policy, resource limits, dan logging driver yang proper.
# docker-compose.prod.yml — extend dari docker-compose.yml
# Jalankan: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
version: '3.9'
services:
app:
image: ghcr.io/organisasi/aplikasi:${APP_VERSION:-latest}
build:
target: runner # Gunakan stage runner (production)
restart: unless-stopped
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
memory: 256M
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
volumes: [] # Hapus volume mount dev
postgres:
restart: always
deploy:
resources:
limits:
memory: 1G
Networking & Volumes
Dalam Docker Compose, container yang berada di network yang sama bisa saling berkomunikasi menggunakan nama service sebagai hostname — itulah kenapa URL database menggunakan postgres:5432 bukan localhost:5432. Volumes memastikan data persisten meski container di-restart atau di-recreate. Gunakan named volumes (bukan bind mount) untuk data database di produksi agar lifecycle-nya dikelola Docker, bukan bergantung pada path host yang bisa berubah.
Awas. Jangan simpan secrets (password, API key) langsung di
docker-compose.ymlyang masuk ke version control. Gunakan file.env(masuk ke.gitignore) atau Docker Secrets untuk produksi. Minimal, pastikan.envselalu ada di.gitignoresebelumgit init.
CI/CD Pipeline
CI/CD — Continuous Integration dan Continuous Deployment — adalah praktik mengotomatisasi seluruh perjalanan kode dari laptop developer ke server produksi. Setiap kali developer push kode ke repository, pipeline CI/CD secara otomatis menjalankan test, membangun artifact, dan mendeploy ke lingkungan yang sesuai. Praktik ini bukan kemewahan — ini adalah keharusan untuk tim yang ingin bergerak cepat tanpa mengorbankan keandalan.
Mengapa CI/CD Penting?
Tanpa CI/CD, proses deploy adalah ritual manual yang rawan kesalahan: pull kode, install dependencies, build, copy file, restart server — dan jika ada yang salah, rollback menjadi operasi forensik. Dengan CI/CD, proses ini terjadi secara konsisten, terdokumentasi, dan bisa di-audit. Bug terdeteksi di pipeline sebelum mencapai produksi. Deployment menjadi peristiwa biasa yang bisa dilakukan puluhan kali sehari, bukan ritual menakutkan sekali seminggu.
GitHub Actions: Pipeline Lengkap
GitHub Actions adalah CI/CD yang terintegrasi langsung di GitHub. Pipeline didefinisikan sebagai YAML di folder .github/workflows/. Setiap workflow terdiri dari trigger (kapan berjalan), jobs (unit kerja paralel atau sequential), dan steps (perintah individual).
# .github/workflows/deploy.yml
# Pipeline: Test → Build → Deploy ke Staging → (manual gate) → Deploy ke Produksi
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ── JOB 1: Test & Lint ──────────────────────────────────────────────────────
test:
name: Test & Lint
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Run unit tests
run: npm run test -- --coverage
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Run database migrations (test)
run: npm run db:migrate
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Upload coverage ke Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
# ── JOB 2: Build & Push Image ───────────────────────────────────────────────
build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Login ke GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build & push image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ── JOB 3: Deploy ke Staging ────────────────────────────────────────────────
deploy-staging:
name: Deploy ke Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy via SSH ke staging server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: deployer
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /var/www/aplikasi-staging
export APP_VERSION=${{ github.sha }}
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps app
docker image prune -f
# ── JOB 4: Deploy ke Produksi (manual approval) ─────────────────────────────
deploy-production:
name: Deploy ke Produksi
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://aplikasi.co.id
steps:
- name: Deploy via SSH ke production server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: deployer
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/aplikasi
export APP_VERSION=${{ github.sha }}
# Rolling update — zero downtime
docker compose pull app
docker compose up -d --no-deps --scale app=2 app
sleep 10
docker compose up -d --no-deps --scale app=1 app
docker compose exec -T postgres psql -U pguser -d appdb \
-c "SELECT 'Migration check';"
docker image prune -f
echo "Deploy ${{ github.sha }} berhasil pada $(date)"
Deployment Strategies: Blue-Green & Rolling
Strategi deployment menentukan bagaimana versi baru menggantikan versi lama tanpa downtime. Blue-Green: jalankan dua environment identik (blue = produksi saat ini, green = versi baru). Setelah green ter-verify, alihkan load balancer dari blue ke green. Rollback semudah mengembalikan pointer ke blue. Rolling: ganti instance satu per satu secara bertahap. Selama proses, sebagian trafik dilayani versi lama, sebagian versi baru. Lebih hemat resource dari blue-green.
| Strategi | Cara Kerja | Downtime | Resource Extra | Rollback |
|---|---|---|---|---|
| Recreate | Matikan semua, deploy baru | Ada (detik–menit) | 0 | Deploy ulang versi lama |
| Rolling | Ganti instance bertahap | Tidak ada | Kecil (1 instance extra) | Roll back per instance |
| Blue-Green | Dua environment, switch pointer | Tidak ada | 2x resource | Instan (switch balik) |
| Canary | Deploy ke sebagian kecil user | Tidak ada | Kecil | Alihkan 100% ke versi lama |
Tips. Untuk MVP tahap awal, rolling deploy dengan Docker Compose sudah mencukupi. Jalankan
docker compose up -d --no-deps appyang akan mengganti container app tanpa menyentuh database atau Redis. Downtime praktis nol karena Docker menunggu health check baru sebelum menghentikan yang lama.
Monitoring & Logging
Aplikasi yang tidak dipantau adalah kotak hitam. Anda tidak tahu kapan ia melambat, berapa persen CPU yang terpakai, error apa yang diterima pengguna, atau kapan disk hampir penuh. Monitoring dan logging mengubah kotak hitam itu menjadi sistem transparan yang bisa Anda observasi, diagnosa, dan optimalkan sebelum masalah kecil menjadi krisis besar di jam sibuk.
Tiga Pilar Observability
Observability modern berdiri di atas tiga pilar: Metrics adalah angka numerik yang diukur dari waktu ke waktu — CPU usage, memory, request per second, error rate. Metrics efisien untuk alerting dan dashboard. Logs adalah rekaman kejadian berteks dengan timestamp — siapa melakukan apa, kapan, dan hasilnya. Logs kaya konteks, bagus untuk debugging. Traces melacak perjalanan satu request melewati multiple service — berguna untuk distributed system. Untuk MVP, fokus pada metrics dan logs sudah cukup.
Server Monitoring: Netdata
Netdata adalah tool monitoring real-time yang sangat ringan, mudah di-install, dan memiliki dashboard web yang indah tanpa konfigurasi rumit. Cocok untuk startup yang butuh visibilitas server segera.
# Install Netdata (satu perintah)
wget -O /tmp/netdata-kickstart.sh https://get.netdata.cloud/kickstart.sh
sh /tmp/netdata-kickstart.sh --stable-channel --disable-telemetry
# Netdata berjalan di port 19999
# Akses via: http://IP_SERVER:19999
# Amankan dengan basic auth via Nginx (jangan ekspos langsung ke internet)
# Di /etc/nginx/sites-available/monitoring.conf:
# location /netdata/ {
# auth_basic "Monitoring";
# auth_basic_user_file /etc/nginx/.htpasswd;
# proxy_pass http://127.0.0.1:19999/;
# }
# Konfigurasi alert email di /etc/netdata/health_alarm_notify.conf
# EMAIL_SENDER="alerts@aplikasi.co.id"
# SEND_EMAIL="YES"
# DEFAULT_RECIPIENT_EMAIL="devops@aplikasi.co.id"
Prometheus + Grafana: Stack Monitoring Profesional
Prometheus adalah time-series database yang mem-pull metrics dari berbagai exporter. Grafana adalah platform visualisasi yang membaca data dari Prometheus (dan banyak sumber lain) untuk ditampilkan sebagai dashboard yang bisa dikonfigurasi bebas. Kombinasi ini adalah standar industri untuk monitoring production.
# docker-compose.monitoring.yml
# Jalankan terpisah dari stack utama
version: '3.9'
services:
prometheus:
image: prom/prometheus:latest
restart: unless-stopped
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
- '--web.enable-lifecycle'
ports:
- "9090:9090"
networks:
- monitoring
grafana:
image: grafana/grafana:latest
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_SERVER_ROOT_URL: https://grafana.aplikasi.co.id
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
ports:
- "3001:3000"
networks:
- monitoring
# Node exporter — metrics server Linux
node-exporter:
image: prom/node-exporter:latest
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
networks:
- monitoring
# PostgreSQL exporter
postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest
restart: unless-stopped
environment:
DATA_SOURCE_NAME: "postgresql://pguser:pgpass@postgres:5432/appdb?sslmode=disable"
networks:
- monitoring
volumes:
prometheus_data:
grafana_data:
networks:
monitoring:
driver: bridge
# monitoring/prometheus.yml — konfigurasi scrape target
global:
scrape_interval: 15s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
rule_files:
- "rules/*.yml"
scrape_configs:
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
- job_name: 'postgres'
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: 'aplikasi'
metrics_path: '/api/metrics' # Endpoint metrics dari aplikasi Next.js
static_configs:
- targets: ['app:3000']
# Contoh alert rule (rules/alerts.yml):
# - alert: HighCPU
# expr: 100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100) > 80
# for: 5m
# labels:
# severity: warning
# annotations:
# summary: "CPU usage tinggi di {{ $labels.instance }}"
Application Monitoring: Sentry
Sentry menangkap error dari aplikasi secara real-time — dengan stack trace lengkap, informasi browser/OS pengguna, dan konteks request. Sentry gratis untuk volume kecil, sangat mudah di-setup di Next.js.
# Install Sentry SDK
npm install @sentry/nextjs
# npx @sentry/wizard@latest -i nextjs
# Wizard akan membuat sentry.client.config.ts, sentry.server.config.ts,
# sentry.edge.config.ts, dan menambahkan instrumen di next.config.js
# sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Tangkap 10% traces di produksi untuk performance monitoring
beforeSend(event) {
// Filter event tertentu jika perlu
if (event.user) {
delete event.user.ip_address; // GDPR compliance
}
return event;
},
});
Log Management dengan Structured Logging
Log yang baik adalah log yang terstruktur — format JSON dengan field konsisten sehingga bisa di-parse dan di-query. Gunakan library seperti pino (Node.js) yang menghasilkan JSON logs. Di produksi, alirkan logs ke sistem terpusat seperti Loki (pasangan Grafana) atau Elasticsearch.
Tips. Minimal wajib ada: uptime monitoring (gunakan Better Uptime atau UptimeRobot gratis — alert ke Telegram/WhatsApp ketika situs down). Satu rule sederhana: alert jika CPU > 80% selama 5 menit berturut-turut, disk > 85%, memory > 90%. Pasang ini di hari pertama produksi, bukan setelah ada insiden.
Backup & Disaster Recovery
Tidak ada yang bertanya apakah disaster akan terjadi — hanya kapan. Hard disk bisa gagal, database bisa korup karena bug migrasi, server bisa terkena ransomware, atau developer bisa tidak sengaja menjalankan DROP TABLE di produksi. Strategi backup yang solid dan rencana disaster recovery yang telah diuji adalah perbedaan antara insiden yang diselesaikan dalam hitungan jam versus kehilangan data permanen yang mengakhiri bisnis.
Prinsip 3-2-1 Backup
Prinsip 3-2-1 adalah standar emas backup: simpan 3 salinan data, di 2 media berbeda, dengan 1 salinan di lokasi lain. Untuk startup: database backup di server itu sendiri (salinan 1), di object storage S3/Wasabi (salinan 2 di media berbeda), dan di cloud provider yang berbeda atau daerah yang berbeda (salinan 3 di lokasi lain). Backup yang hanya ada di satu tempat tidak dihitung sebagai backup.
Database Backup: PostgreSQL
PostgreSQL menyediakan pg_dump untuk logical backup (SQL atau custom format) dan pg_basebackup untuk physical backup. Untuk MVP, pg_dump dalam format custom (-Fc) sudah sangat cukup — menghasilkan file terkompresi yang bisa di-restore selektif per tabel.
#!/bin/bash
# /opt/scripts/backup-database.sh
# Jalankan via cron: 0 2 * * * /opt/scripts/backup-database.sh
set -euo pipefail
# ─── KONFIGURASI ──────────────────────────────────────────────────────────────
DB_NAME="appdb"
DB_USER="pguser"
DB_HOST="localhost"
BACKUP_DIR="/var/backups/postgresql"
S3_BUCKET="s3://backup-aplikasi-co-id/postgresql"
RETENTION_DAYS=30
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.dump"
LOG_FILE="/var/log/backup-database.log"
# ─── FUNGSI LOGGING ───────────────────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "${LOG_FILE}"; }
# ─── MULAI BACKUP ─────────────────────────────────────────────────────────────
log "=== Mulai backup ${DB_NAME} ==="
# Pastikan direktori ada
mkdir -p "${BACKUP_DIR}"
# Dump database dalam format custom (terkompresi)
PGPASSWORD="${DB_PASSWORD}" pg_dump \
-h "${DB_HOST}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
--format=custom \
--compress=9 \
--no-password \
--file="${BACKUP_FILE}"
# Verifikasi file backup ada dan tidak kosong
if [[ ! -s "${BACKUP_FILE}" ]]; then
log "ERROR: Backup file kosong atau tidak ada!"
exit 1
fi
BACKUP_SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1)
log "Backup selesai: ${BACKUP_FILE} (${BACKUP_SIZE})"
# ─── UPLOAD KE S3 ─────────────────────────────────────────────────────────────
log "Upload ke S3: ${S3_BUCKET}"
aws s3 cp "${BACKUP_FILE}" "${S3_BUCKET}/" \
--storage-class STANDARD_IA \
--sse AES256
log "Upload S3 selesai"
# ─── ENKRIPSI BACKUP LOKAL (opsional, gunakan GPG) ────────────────────────────
# gpg --batch --yes --recipient backup@aplikasi.co.id \
# --encrypt "${BACKUP_FILE}"
# rm "${BACKUP_FILE}"
# ─── HAPUS BACKUP LAMA ────────────────────────────────────────────────────────
log "Hapus backup lokal > ${RETENTION_DAYS} hari"
find "${BACKUP_DIR}" -name "*.dump" -mtime +${RETENTION_DAYS} -delete
# Hapus juga dari S3 (menggunakan S3 lifecycle policy lebih disarankan)
# aws s3 ls "${S3_BUCKET}/" | ...
log "=== Backup selesai ==="
# ─── KIRIM NOTIFIKASI ─────────────────────────────────────────────────────────
# Kirim ke Telegram bot atau Slack webhook
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d text="Backup DB ${DB_NAME} berhasil: ${BACKUP_SIZE} (${TIMESTAMP})" \
> /dev/null || true
# ─── CRON JOBS BACKUP ─────────────────────────────────────────────────────────
# Edit dengan: crontab -e -u deployer
# Backup database setiap hari jam 02:00
0 2 * * * /opt/scripts/backup-database.sh
# Backup file upload setiap hari jam 03:00
0 3 * * * /opt/scripts/backup-files.sh
# Backup mingguan ke cold storage (S3 Glacier) setiap Minggu jam 04:00
0 4 * * 0 /opt/scripts/backup-weekly-archive.sh
# Test restore bulanan — jalankan manual, jangan di cron produksi
# 0 6 1 * * /opt/scripts/test-restore.sh
File & Media Backup
File yang di-upload pengguna (foto produk, dokumen, avatar) perlu backup terpisah dari database. Gunakan rclone untuk sinkronisasi ke berbagai cloud storage provider dengan satu tool.
#!/bin/bash
# /opt/scripts/backup-files.sh
UPLOAD_DIR="/var/www/aplikasi/public/uploads"
S3_DEST="s3:backup-aplikasi-co-id/uploads"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Sinkronisasi incremental (hanya yang berubah)
rclone sync "${UPLOAD_DIR}" "${S3_DEST}" \
--s3-storage-class STANDARD_IA \
--transfers 10 \
--checkers 20 \
--log-file /var/log/backup-files.log \
--log-level INFO \
--stats 60s
echo "[${TIMESTAMP}] File backup selesai"
# Untuk backup snapshot full (tarball):
# tar -czf "/var/backups/uploads_${TIMESTAMP}.tar.gz" "${UPLOAD_DIR}"
# aws s3 cp "/var/backups/uploads_${TIMESTAMP}.tar.gz" "${S3_DEST}/snapshots/"
Prosedur Restore & Testing
Backup tanpa test restore adalah ilusi keamanan. Jadwalkan restore test bulanan ke environment staging. Dokumentasikan RTO (Recovery Time Objective — berapa lama downtime yang dapat ditoleransi) dan RPO (Recovery Point Objective — berapa lama data yang boleh hilang). Untuk MVP tipikal: RTO < 4 jam, RPO < 24 jam.
#!/bin/bash
# /opt/scripts/restore-database.sh
# HANYA dijalankan di server staging untuk test restore!
BACKUP_FILE="$1" # Argumen: path ke file .dump
RESTORE_DB="appdb_restore_test"
DB_USER="pguser"
if [[ -z "${BACKUP_FILE}" ]]; then
echo "Usage: $0 <backup_file.dump>"
exit 1
fi
echo "=== Restore test dari: ${BACKUP_FILE} ==="
# Buat database baru untuk test (jangan timpa produksi!)
PGPASSWORD="${DB_PASSWORD}" createdb -U "${DB_USER}" "${RESTORE_DB}" 2>/dev/null || true
# Restore
PGPASSWORD="${DB_PASSWORD}" pg_restore \
-U "${DB_USER}" \
-d "${RESTORE_DB}" \
--no-owner \
--no-privileges \
--verbose \
"${BACKUP_FILE}"
# Verifikasi jumlah record penting
PGPASSWORD="${DB_PASSWORD}" psql -U "${DB_USER}" -d "${RESTORE_DB}" -c "
SELECT
(SELECT COUNT(*) FROM users WHERE deleted_at IS NULL) AS total_users,
(SELECT COUNT(*) FROM orders WHERE deleted_at IS NULL) AS total_orders,
(SELECT MAX(created_at) FROM orders) AS last_order_at;
"
echo "=== Restore test selesai. Verifikasi manual data di ${RESTORE_DB} ==="
# Hapus database test setelah verifikasi
# PGPASSWORD="${DB_PASSWORD}" dropdb -U "${DB_USER}" "${RESTORE_DB}"
Disaster Recovery Plan
DRP adalah dokumen yang menjawab: apa yang dilakukan ketika server tidak bisa diakses sama sekali? Untuk startup, DRP minimal mencakup: (1) kontak darurat anggota tim, (2) credential akses ke semua sistem (disimpan di password manager seperti Bitwarden, bukan di kepala satu orang), (3) langkah step-by-step untuk provision server baru dari nol, (4) cara restore backup terbaru, (5) cara update DNS ke server baru.
Awas. Skenario terburuk yang sering diabaikan: developer kunci satu-satunya yang memegang akses server mengundurkan diri mendadak. Pastikan minimal dua orang memiliki akses ke semua credential dan mampu menjalankan DRP. Simpan DRP di Notion atau Google Docs yang dapat diakses seluruh tim — bukan di laptop satu orang.
Scaling Strategy
Aplikasi yang berhasil adalah aplikasi yang harus di-scale. Ketika pengguna bertambah dari ratusan ke ribuan ke jutaan, arsitektur awal yang bekerja sempurna di VPS 4 GB mulai menunjukkan batasnya: CPU melonjak, database menjadi bottleneck, halaman menjadi lambat. Memahami strategi scaling sejak awal memungkinkan Anda membuat keputusan arsitektur yang tepat — tidak over-engineer di awal, tetapi tidak pula terjebak di dead end ketika perlu tumbuh.
Vertical vs Horizontal Scaling
Scaling vertical berarti menambah resource pada satu server yang sama — lebih banyak CPU, RAM, atau disk. Ini solusi tercepat dan paling sederhana: cukup upgrade plan VPS atau instance type. Batasnya: ada ceiling fisik, dan downtime biasanya diperlukan saat resize. Scaling horizontal berarti menambah lebih banyak server yang bekerja paralel di belakang load balancer. Lebih kompleks, tetapi tidak ada ceiling teoritis dan bisa dilakukan tanpa downtime. Prinsip: scale vertical terlebih dahulu (lebih mudah), scale horizontal ketika mendekati batas vertikal.
| Dimensi | Vertical Scaling | Horizontal Scaling |
|---|---|---|
| Cara kerja | Upgrade resource satu server | Tambah banyak server |
| Kompleksitas | Rendah — tidak perlu ubah arsitektur | Tinggi — butuh load balancer, session handling, shared storage |
| Downtime saat scale | Biasanya ada (restart instance) | Tidak ada (tambah node sambil jalan) |
| Batas maksimal | Ada (terbatas hardware terbesar) | Hampir tidak ada (teoritis) |
| Biaya efisiensi | Lebih efisien di skala kecil–menengah | Lebih efisien di skala besar dengan auto-scaling |
| Session management | Tidak perlu ubah | Wajib session store eksternal (Redis) |
| Cocok untuk | MVP hingga 50k pengguna aktif | 50k+ pengguna, traffic tidak merata |
Load Balancer
Load balancer mendistribusikan trafik masuk ke beberapa instance aplikasi. Nginx bisa berfungsi sebagai load balancer sederhana menggunakan direktif upstream. Untuk produksi yang lebih serius, gunakan managed load balancer seperti AWS ALB, GCP Load Balancer, atau HAProxy.
# Nginx sebagai load balancer (upstream)
# /etc/nginx/conf.d/upstream.conf
upstream app_cluster {
# Least connections — kirim ke server dengan koneksi aktif paling sedikit
least_conn;
server 10.0.1.10:3000 weight=3; # Server A (lebih powerful)
server 10.0.1.11:3000 weight=2; # Server B
server 10.0.1.12:3000 weight=1 backup; # Server C — hanya jika A & B down
keepalive 32; # Pertahankan 32 koneksi persistent
}
server {
listen 443 ssl http2;
server_name aplikasi.co.id;
# ... (SSL config)
location / {
proxy_pass http://app_cluster;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Health check — hapus server down dari pool
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
}
Database Scaling: Read Replicas
Database sering menjadi bottleneck pertama. Sebagian besar aplikasi memiliki pola baca jauh lebih banyak dari tulis (read-heavy). Solusi pertama: tambahkan read replica — salinan database yang sinkron dari primary, khusus untuk query SELECT. Tulis (INSERT/UPDATE/DELETE) tetap ke primary; baca berat (laporan, export, dashboard analytics) diarahkan ke replica.
// Konfigurasi koneksi database dengan read replica
// lib/database.ts
import { Pool } from 'pg';
// Primary — untuk semua write operations
const primaryPool = new Pool({
host: process.env.DB_PRIMARY_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20,
idleTimeoutMillis: 30000,
});
// Read replica — untuk read-heavy queries
const replicaPool = new Pool({
host: process.env.DB_REPLICA_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 30, // Lebih banyak koneksi karena load lebih tinggi
idleTimeoutMillis: 30000,
});
// Helper: pilih pool berdasarkan jenis operasi
export function getPool(readonly: boolean = false): Pool {
if (readonly && process.env.DB_REPLICA_HOST) {
return replicaPool;
}
return primaryPool;
}
// Penggunaan:
// const pool = getPool(true); // Baca dari replica
// const pool = getPool(false); // Tulis ke primary
// Contoh di service layer:
export async function getOrderList(userId: string) {
const pool = getPool(true); // Read-only — gunakan replica
const result = await pool.query(
'SELECT * FROM orders WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC',
[userId]
);
return result.rows;
}
export async function createOrder(data: OrderData) {
const pool = getPool(false); // Write — gunakan primary
const result = await pool.query(
'INSERT INTO orders (user_id, total, status) VALUES ($1, $2, $3) RETURNING *',
[data.userId, data.total, 'pending']
);
return result.rows[0];
}
Caching Layer
Redis sebagai caching layer dapat mendramatisasi performa dengan mengurangi beban database. Cache respons API yang sering diakses, session pengguna, dan hasil komputasi berat. Strategi cache yang umum: Cache-Aside (aplikasi cek cache dulu, miss → ambil dari DB → simpan ke cache), Write-Through (tulis ke cache dan DB bersamaan), Write-Behind (tulis ke cache dahulu, async sync ke DB).
// lib/cache.ts — cache-aside pattern dengan Redis
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function getCached<T>(
key: string,
ttl: number,
fetcher: () => Promise<T>
): Promise<T> {
// 1. Cek cache
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
// 2. Cache miss — ambil dari sumber
const data = await fetcher();
// 3. Simpan ke cache dengan TTL
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Penggunaan:
async function getProductDetail(productId: string) {
return getCached(
`product:${productId}`,
300, // Cache 5 menit
() => db.query('SELECT * FROM products WHERE id = $1', [productId])
);
}
// Invalidasi cache saat produk diupdate:
async function updateProduct(productId: string, data: Partial<Product>) {
await db.query('UPDATE products SET ...', [...]);
await redis.del(`product:${productId}`); // Invalidate cache
}
CDN untuk Static Assets
CDN (Content Delivery Network) mendistribusikan static assets — gambar, CSS, JS, font — ke edge server yang dekat secara geografis dengan pengguna. Pengguna di Surabaya yang mengakses aplikasi dengan CDN node di Jakarta akan mendapatkan gambar jauh lebih cepat dibanding harus ke server di Singapura. Cloudflare menyediakan CDN gratis yang bisa aktif dalam hitungan menit hanya dengan mengubah nameserver domain.
Kapan Harus Scale?
Jangan scale karena takut atau antisipasi berlebihan — scale karena data. Pantau metrics berikut dan scale ketika threshold tercapai secara konsisten selama lebih dari 30 menit: CPU > 70%, Memory > 80%, rata-rata response time > 500ms, error rate > 1%. Jangan menunggu hingga server 100% CPU — scale di 70% memberi ruang buffer untuk traffic spike.
# Cek resource usage saat ini (jalankan di server)
# CPU dan memory per proses:
htop
# Koneksi database aktif:
# Di psql:
# SELECT count(*), state FROM pg_stat_activity GROUP BY state;
# Ukuran tabel dan index PostgreSQL:
# SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
# FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 10;
# Queue Redis:
redis-cli -a "${REDIS_PASSWORD}" INFO stats | grep -E "total_commands|instantaneous_ops"
# Nginx connection stats:
curl http://127.0.0.1/nginx_status
Rumus. Urutan optimasi sebelum scaling: (1) Identifikasi bottleneck dengan profiling nyata, bukan asumsi. (2) Optimasi query lambat (tambah index, rewrite query). (3) Tambahkan caching di layer yang tepat. (4) Scale vertical (upgrade VPS). (5) Baru scale horizontal. Setiap langkah bisa memberikan peningkatan 2–10x performa sebelum perlu ke langkah berikutnya. Premature scaling adalah pemborosan biaya.
Stateless Application: Kunci Horizontal Scaling
Aplikasi harus stateless agar bisa di-scale horizontal. Artinya: satu request tidak boleh bergantung pada state yang tersimpan di memory atau disk server tertentu. Session pengguna harus disimpan di Redis (bukan in-memory). File upload harus ke object storage (bukan disk lokal server). Jika request pertama dilayani server A dan request kedua dari user sama dilayani server B, hasilnya harus identik. Aplikasi yang sudah mengikuti prinsip ini — session di Redis, file di S3-compatible storage — bisa di-scale horizontal kapan saja tanpa refactor besar.
Tips. Gunakan Wasabi atau MinIO (self-hosted) sebagai alternatif S3 yang lebih murah untuk startup Indonesia. Wasabi tidak mengenakan biaya egress (download), yang bisa menjadi penghematan signifikan dibanding AWS S3 ketika traffic meningkat.
7 Layer OSI & Jenis Aplikasi
7 Layer OSI untuk Developer
Model OSI (Open Systems Interconnection) lahir pada 1984 sebagai kerangka konseptual yang membagi komunikasi jaringan menjadi tujuh lapisan yang independen namun saling bergantung. Bagi developer yang membangun aplikasi web, mobile, atau IoT di tahun 2024, memahami OSI bukan sekadar hafalan ujian sertifikasi — ini adalah kompas untuk mendiagnosis bug jaringan, merancang arsitektur yang tahan banting, dan berbicara satu bahasa dengan tim infrastruktur ketika produksi sedang down pukul dua pagi. Setiap kali request HTTP gagal, setiap kali WebSocket terputus tiba-tiba, setiap kali payload IoT tidak sampai ke cloud, masalahnya bisa dilacak ke salah satu dari tujuh layer ini.
Inti. OSI bukan protokol nyata — ia adalah kerangka berpikir. Protokol nyata (TCP/IP) memetakan ke layer OSI, bukan sebaliknya. Pahami layer, dan Anda tahu di mana harus mencari masalah.
Layer 1 — Physical: Dunia Nyata Bertemu Digital
Layer Physical menangani bit mentah — sinyal listrik, gelombang radio, cahaya serat optik, atau pulsa inframerah. Ini bukan urusan developer biasanya, tetapi bagi mereka yang membangun perangkat IoT, sistem embedded, atau instalasi jaringan industri, Layer 1 sangat kritis. Kecepatan kabel (Cat5e vs Cat6 vs fiber), frekuensi Wi-Fi (2.4 GHz vs 5 GHz), antena ESP32, dan pin GPIO semuanya hidup di Layer 1.
Developer IoT wajib memahami batasan fisik: sinyal 2.4 GHz menembus tembok lebih baik tetapi lebih padat interferensi; 5 GHz lebih cepat tetapi jangkauan lebih pendek. ESP8266 hanya mendukung 2.4 GHz; ESP32 mendukung keduanya plus Bluetooth. Keputusan ini menentukan apakah sensor Anda di gudang bisa terhubung ke router yang ada di kantor depan.
Layer 2 — Data Link: Switch, MAC, dan Frame
Layer Data Link bertanggung jawab atas transfer data antara dua node yang terhubung langsung — baik melalui kabel Ethernet maupun Wi-Fi. Protokol utama di layer ini adalah Ethernet (IEEE 802.3) dan Wi-Fi (IEEE 802.11). Setiap perangkat jaringan punya MAC address yang unik secara global, 48-bit yang ditulis sebagai AA:BB:CC:DD:EE:FF.
Switch beroperasi di Layer 2. Ia membaca MAC address dan menentukan ke port mana frame harus dikirim, tanpa perlu tahu soal IP. Ini mengapa switch lebih cepat dari router untuk lalu lintas lokal. Developer yang membangun sistem keuangan atau inventory dengan banyak terminal di LAN perlu memahami bahwa bottleneck sering ada di Layer 2 — misalnya broadcast storm atau duplikat MAC address.
Layer 3 — Network: IP Routing dan Firewall
Layer Network adalah wilayah IP address, routing, dan firewall. Setiap paket data di internet dibungkus dengan header IP yang berisi IP sumber dan tujuan. Router membaca header ini dan memutuskan jalur terbaik untuk meneruskan paket — bisa melewati Telkom, Indosat, atau Google's backbone, tergantung routing table.
Firewall berbasis iptables atau nftables beroperasi di Layer 3 (dan Layer 4). Ketika Anda membuka port 443 di VPS untuk HTTPS atau memblokir range IP tertentu, Anda bekerja di Layer 3. Developer yang men-deploy di VPS bare-metal atau Proxmox harus familiar dengan konsep subnet, CIDR notation (192.168.1.0/24), dan NAT (Network Address Translation) yang membuat beberapa server berbagi satu IP publik.
Layer 4 — Transport: TCP, UDP, dan Port
Layer Transport menyediakan end-to-end communication antara proses di dua host. Dua protokol utamanya adalah TCP dan UDP — pilihan antara keduanya menentukan reliabilitas versus kecepatan transfer data.
TCP (Transmission Control Protocol) menjamin pengiriman: ada handshake tiga arah, nomor sequence, acknowledgment, dan retransmisi jika paket hilang. HTTP/1.1, HTTP/2, SMTP, SSH, dan FTP semua menggunakan TCP. Cocok untuk transfer file, transaksi database, dan API request di mana data harus sampai utuh dan berurutan.
UDP (User Datagram Protocol) tidak ada jaminan: paket dikirim dan dilupakan. Cocok untuk video streaming, VoIP, game online, dan DNS lookup — situasi di mana lebih baik kehilangan satu frame daripada menunggu retransmisi yang membuat tampilan macet. WebRTC (yang digunakan Google Meet dan WhatsApp video call) menggunakan UDP di bawahnya.
Port adalah alamat proses di dalam sebuah host. Port 80 untuk HTTP, 443 untuk HTTPS, 22 untuk SSH, 3306 untuk MySQL, 5432 untuk PostgreSQL. Range 0–1023 adalah well-known ports yang membutuhkan hak root untuk di-bind; range 1024–65535 bisa digunakan aplikasi biasa.
Layer 5 — Session: Manajemen Koneksi Berkelanjutan
Layer Session mengelola pembukaan, pemeliharaan, dan penutupan sesi komunikasi antara dua aplikasi. Di era web modern, konsep yang paling relevan di layer ini adalah WebSocket — protokol yang memungkinkan koneksi duplex penuh yang persisten antara browser dan server, berbeda dengan HTTP yang request-response stateless.
WebSocket dimulai sebagai HTTP upgrade request, lalu "naik" ke koneksi persisten. Setelah upgrade, baik server maupun client bisa mengirim data kapan saja tanpa perlu request baru. Ini fondasi dari fitur real-time: chat, notifikasi live, kolaborasi dokumen, dashboard monitoring, dan live trading.
Layer 6 — Presentation: Enkripsi, Kompresi, dan Format Data
Layer Presentation bertanggung jawab atas bagaimana data dikodekan, dikompresi, dan dienkripsi sebelum dikirim. Dalam konteks web modern, SSL/TLS beroperasi di layer ini. Ketika browser Anda melihat gembok hijau dan URL dimulai dengan https://, TLS sedang bekerja di Layer 6 — mengenkripsi semua data agar tidak bisa dibaca pihak ketiga di antara client dan server.
Format serialisasi data juga hidup di sini: JSON, XML, Protocol Buffers, MessagePack. Kompresi Gzip dan Brotli yang membuat halaman web lebih cepat diunduh juga Layer 6. Developer harus memastikan server mengirim header Content-Encoding: gzip dan client bisa menerimanya via Accept-Encoding.
Layer 7 — Application: HTTP, DNS, SMTP, dan API
Layer Application adalah yang paling dekat dengan developer sehari-hari. Semua protokol yang kita gunakan langsung — HTTP/HTTPS, DNS, SMTP/IMAP, FTP, MQTT, AMQP — hidup di sini. REST API, GraphQL, gRPC, WebSocket handshake, dan semua logika aplikasi Anda beroperasi di Layer 7.
Load balancer Layer 7 (seperti Nginx atau AWS ALB) bisa membaca konten HTTP — URL path, header, cookie — dan membuat keputusan routing yang lebih cerdas dibanding Layer 4 yang hanya membaca IP dan port. Ini memungkinkan blue-green deployment, canary release, dan routing berdasarkan tenant ID dalam arsitektur multi-tenant.
| Layer | Nama | Protokol/Teknologi | Relevansi Developer | Contoh Masalah |
|---|---|---|---|---|
| 7 | Application | HTTP, DNS, SMTP, MQTT | Sangat tinggi — semua API ada di sini | 400 Bad Request, CORS error |
| 6 | Presentation | TLS, JSON, Gzip | Tinggi — enkripsi & serialisasi | SSL handshake failure, parse error |
| 5 | Session | WebSocket, RPC | Sedang — koneksi real-time | WebSocket disconnect tanpa pesan |
| 4 | Transport | TCP, UDP, Port | Sedang — port & firewall | Connection timeout, port blocked |
| 3 | Network | IP, Router, Firewall | Sedang — routing & VPN | Unreachable host, routing loop |
| 2 | Data Link | Ethernet, Wi-Fi, MAC | Rendah (kecuali IoT/LAN) | Duplikat MAC, broadcast storm |
| 1 | Physical | Kabel, Fiber, GPIO | Rendah (kecuali embedded) | Kabel rusak, sinyal lemah |
# Diagnosa layer per layer dengan tools CLI
# Layer 1 & 2 — cek koneksi fisik dan MAC
ip link show # interface up/down
arp -n # ARP table (MAC address mapping)
# Layer 3 — routing dan IP
ip addr show # IP address kita
ip route show # routing table
ping 8.8.8.8 # test konektivitas IP
traceroute 8.8.8.8 # lihat path routing hop by hop
# Layer 4 — port dan koneksi TCP/UDP
ss -tlnp # port yang sedang listen
curl -v --max-time 5 http://target # test koneksi TCP ke port HTTP
nc -zv target.com 443 # cek apakah port 443 terbuka
# Layer 7 — HTTP application layer
curl -I https://api.example.com/health # cek HTTP headers
curl -v -X POST https://api.example.com/login \
-H "Content-Type: application/json" \
-d '{"email":"test@mail.com","password":"xxx"}' # full request debug
# Python: bangun pemahaman layer dengan socket raw (Layer 4)
import socket
import json
# Koneksi TCP eksplisit ke Layer 4 (Transport)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('api.example.com', 443)) # TCP handshake terjadi di sini
# Wrap dengan TLS (Layer 6 — Presentation)
import ssl
ctx = ssl.create_default_context()
tls_sock = ctx.wrap_socket(sock, server_hostname='api.example.com')
# Kirim HTTP request (Layer 7 — Application)
request = (
"GET /health HTTP/1.1\r\n"
"Host: api.example.com\r\n"
"Connection: close\r\n\r\n"
)
tls_sock.sendall(request.encode())
response = tls_sock.recv(4096)
print(response.decode())
tls_sock.close()
Tips. Saat debugging koneksi, mulai dari Layer 1 ke atas: pastikan fisik OK → cek IP → cek port → cek protokol aplikasi. Jangan langsung curigai kode aplikasi Anda jika masalahnya ada di layer bawah.
Aplikasi Browser: Extension & Plugin
Browser extension adalah salah satu kategori produk digital yang paling underrated dari sudut pandang bisnis: distribusi gratis via Chrome Web Store, akses ke jutaan pengguna tanpa biaya akuisisi, dan kemampuan untuk menyuntikkan fungsionalitas ke halaman web manapun yang dibuka pengguna. Sebuah extension AI yang benar-benar berguna — summarizer artikel, grammar checker, atau auto-filler formulir — bisa meraih puluhan ribu pengguna aktif dalam hitungan bulan tanpa anggaran marketing. Ini adalah salah satu entry point terbaik untuk developer indie yang ingin memvalidasi ide dengan cepat.
Arsitektur Chrome Extension (Manifest V3)
Chrome Extension modern menggunakan Manifest V3 (MV3) yang diperkenalkan Google pada 2023 sebagai pengganti MV2. MV3 memperkenalkan service worker menggantikan background page yang persisten, serta declarativeNetRequest API yang lebih aman untuk modifikasi network request. Tiga komponen utama sebuah extension:
1. manifest.json — File konfigurasi utama yang mendeklarasikan nama, versi, permissions, dan file yang digunakan. Ini yang pertama dibaca browser saat extension diinstall.
2. Background Service Worker — Script yang berjalan di background, menangani event seperti tab dibuka, pesan dari content script, dan alarm. Tidak punya akses DOM karena tidak ada halaman yang ditampilkan. Di MV3, service worker bisa dimatikan browser ketika idle untuk hemat memori.
3. Content Script — JavaScript yang disuntikkan ke halaman web yang sedang dibuka pengguna. Punya akses penuh ke DOM halaman, tapi isolated dari JavaScript halaman itu sendiri (tidak bisa akses variabel global halaman). Berkomunikasi dengan background via Message Passing API.
4. Popup — UI kecil yang muncul ketika pengguna klik ikon extension di toolbar. File HTML biasa dengan JavaScript terpisah.
// manifest.json — Chrome Extension Manifest V3
{
"manifest_version": 3,
"name": "AI Page Summarizer",
"version": "1.0.0",
"description": "Rangkum halaman web dengan AI dalam satu klik",
"permissions": [
"activeTab",
"storage",
"contextMenus"
],
"host_permissions": [
"https://api.openai.com/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_page": "options.html"
}
Content Script + AI: Menyuntikkan Kecerdasan ke Halaman Manapun
Kombinasi content script dengan AI API adalah formula dasar untuk extension AI yang powerful. Content script membaca teks dari DOM halaman, mengirimnya ke background service worker via message passing, background service worker memanggil AI API, lalu hasilnya dikirim kembali ke content script untuk ditampilkan.
// content.js — Content Script dengan AI Integration
(function() {
// Baca teks utama halaman (heuristik sederhana)
function extractMainText() {
const article = document.querySelector('article') ||
document.querySelector('main') ||
document.querySelector('.content') ||
document.body;
return article.innerText.slice(0, 8000); // limit 8000 karakter
}
// Tampilkan panel ringkasan di sisi kanan halaman
function showSummaryPanel(summary) {
const existing = document.getElementById('ai-summarizer-panel');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.id = 'ai-summarizer-panel';
panel.style.cssText = `
position: fixed; top: 80px; right: 20px; width: 320px;
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
padding: 16px; box-shadow: 0 8px 32px rgba(0,0,0,.15);
z-index: 999999; font-family: Inter, sans-serif; font-size: 14px;
line-height: 1.6; color: #1f2937;
`;
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<strong style="color:#1e3a8a">Ringkasan AI</strong>
<button onclick="this.closest('#ai-summarizer-panel').remove()"
style="background:none;border:none;cursor:pointer;font-size:18px">×</button>
</div>
<p>${summary}</p>
`;
document.body.appendChild(panel);
}
// Listen pesan dari background service worker
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'SUMMARIZE_PAGE') {
const text = extractMainText();
// Kirim teks ke background untuk dikirim ke AI API
chrome.runtime.sendMessage(
{ type: 'CALL_AI_API', text },
(response) => {
if (response.summary) showSummaryPanel(response.summary);
}
);
}
if (message.type === 'SHOW_SUMMARY') {
showSummaryPanel(message.summary);
}
});
})();
// background.js — Service Worker (Manifest V3)
import { callOpenAI } from './lib/openai.js';
// Handle message dari content script atau popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'CALL_AI_API') {
// Service worker harus return true untuk async response
callOpenAI(message.text).then(summary => {
sendResponse({ summary });
});
return true; // penting! agar sendResponse bisa dipanggil async
}
});
// Context menu — klik kanan > Rangkum Halaman Ini
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'summarize',
title: 'Rangkum dengan AI',
contexts: ['page']
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'summarize') {
chrome.tabs.sendMessage(tab.id, { type: 'SUMMARIZE_PAGE' });
}
});
Firefox Add-on dan Safari Extension
Extension yang ditulis untuk Chrome MV3 umumnya kompatibel dengan Firefox melalui WebExtensions API, meski ada beberapa perbedaan. Firefox menggunakan browser.* API (dengan Promise) sementara Chrome menggunakan chrome.* API (callback-based, meski kini sudah mendukung Promise juga). Library webextension-polyfill dari Mozilla menjembatani perbedaan ini.
Safari Extension membutuhkan wrapper macOS App — Anda perlu Xcode dan akun Apple Developer (Rp 1.5 juta/tahun). Xcode menyediakan tool xcrun safari-web-extension-converter yang mengonversi extension Chrome/Firefox ke format Safari secara otomatis, meski sering butuh penyesuaian manual. Distribusi melalui App Store dengan review Apple yang lebih ketat.
| Platform | API Standard | Store | Review Time | Revenue Share | Catatan |
|---|---|---|---|---|---|
| Chrome | MV3 / chrome.* | Chrome Web Store | 2–7 hari | Gratis list | Terbesar, 65%+ pengguna desktop |
| Firefox | WebExtensions / browser.* | AMO (addons.mozilla.org) | 1–3 hari | Gratis list | Privacy-conscious users |
| Safari | WebExtensions + macOS App | App Store (macOS) | 5–14 hari | 30% (jika berbayar) | Butuh Apple Developer account |
| Edge | MV3 (sama Chrome) | Edge Add-ons Store | 3–7 hari | Gratis list | Gunakan package Chrome yang sama |
Monetisasi Browser Extension
Model freemium paling efektif untuk extension: fitur dasar gratis, fitur premium (lebih banyak AI request/bulan, export, custom prompt, dsb.) di balik langganan. Gunakan Stripe atau LemonSqueezy untuk payment processing; simpan status langganan di server Anda, lalu extension polling API Anda untuk validasi.
Model usage-based juga bekerja baik untuk extension AI: pengguna beli kredit yang dikonsumsi setiap kali memanggil AI. Ini menurunkan barrier masuk dibanding subscription bulanan. Iklan via Google AdSense dalam popup adalah opsi terakhir — pengalaman buruk dan revenue kecil.
Contoh. Extension AI populer di Chrome Web Store: Grammarly (grammar checker, $12/bulan premium), Merlin (ChatGPT di semua halaman), AIPRM (prompt templates ChatGPT). Ketiganya mulai dari extension sederhana dan berkembang ke produk SaaS penuh.
Aplikasi Desktop
Di era cloud dan browser, mungkin terasa aneh membahas aplikasi desktop. Namun kenyataannya, sejumlah kategori aplikasi tetap lebih baik sebagai desktop: software kasir (POS) yang harus bekerja offline, akuntansi dengan integrasi printer struk dan mesin EDC, aplikasi inventory gudang yang berjalan di komputer tua tanpa koneksi internet stabil, dan desktop AI assistant yang membutuhkan akses ke file sistem lokal. Pasar desktop enterprise Indonesia — mulai dari apotek, bengkel, klinik, hingga dinas pemerintah — masih sangat besar dan sering diabaikan developer yang fokus ke web/mobile.
Electron: Web Tech untuk Desktop
Electron menggabungkan Chromium (browser engine) dan Node.js dalam satu paket, memungkinkan Anda membangun aplikasi desktop dengan HTML, CSS, dan JavaScript yang sama digunakan untuk web. VS Code, Slack, Discord, Figma (partial), dan WhatsApp Desktop semuanya dibangun dengan Electron.
Kekuatan Electron: akses penuh ke sistem file, registry Windows, port serial (untuk printer dan mesin EDC), dan kemampuan berjalan offline. Kelemahannya adalah ukuran bundle yang besar (minimum ~150 MB karena menyertakan Chromium) dan konsumsi RAM yang tinggi. Untuk aplikasi bisnis di desktop yang sudah tua dengan RAM 4 GB, ini bisa menjadi masalah.
// main.js — Electron Main Process
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
nodeIntegration: false, // keamanan: matikan nodeIntegration
contextIsolation: true, // isolasi context
preload: path.join(__dirname, 'preload.js')
},
titleBarStyle: 'hiddenInset', // tampilan modern macOS
autoHideMenuBar: true
});
// Load React/Vue app — bisa dari file lokal atau dev server
if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173');
win.webContents.openDevTools();
} else {
win.loadFile(path.join(__dirname, 'dist/index.html'));
}
}
// IPC: renderer minta buka dialog pilih file
ipcMain.handle('dialog:openFile', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'Excel/CSV', extensions: ['xlsx', 'csv'] }]
});
if (canceled) return null;
return fs.readFileSync(filePaths[0]);
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
Tauri: Alternatif Lebih Ringan dengan Rust
Tauri adalah framework desktop modern yang menggunakan webview sistem operasi (bukan bundel Chromium) dan backend Rust. Hasilnya: ukuran binary jauh lebih kecil (bisa di bawah 10 MB), konsumsi RAM lebih rendah, dan keamanan lebih baik. Frontend tetap HTML/CSS/JavaScript (React, Vue, Svelte). Tauri v2 mendukung mobile (Android dan iOS) dari codebase yang sama.
Trade-off: Anda perlu menulis logika native di Rust untuk hal-hal yang Electron bisa lakukan langsung dari Node.js. Untuk developer yang tidak kenal Rust, kurva belajar ada, meski Tauri menyediakan command system yang cukup mudah dipelajari.
// src-tauri/src/main.rs — Tauri Backend (Rust)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs;
use tauri::Manager;
// Command yang bisa dipanggil dari frontend JavaScript
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
fn write_file(path: String, content: String) -> Result<(), String> {
fs::write(&path, content).map_err(|e| e.to_string())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![read_file, write_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// Frontend JavaScript — memanggil Rust command via Tauri
import { invoke } from '@tauri-apps/api/core';
// Baca file dari disk lokal
async function loadConfig() {
try {
const content = await invoke('read_file', {
path: '/Users/user/Documents/config.json'
});
return JSON.parse(content);
} catch (err) {
console.error('Gagal baca file:', err);
}
}
// Simpan data ke disk
async function saveData(data) {
await invoke('write_file', {
path: '/Users/user/Documents/data.json',
content: JSON.stringify(data, null, 2)
});
}
Flutter Desktop dan .NET MAUI
Flutter Desktop memungkinkan satu codebase Dart untuk Windows, macOS, Linux, Android, dan iOS sekaligus. Cocok untuk tim yang sudah membangun mobile app Flutter dan ingin ekspansi ke desktop. .NET MAUI (Multi-platform App UI) adalah pilihan alami untuk tim .NET/C# yang sudah familiar dengan ekosistem Microsoft — terutama untuk aplikasi enterprise Windows yang perlu integrasi dengan Active Directory, SQL Server, atau Office 365.
| Framework | Bahasa | Platform | Ukuran Binary | Kelebihan | Kekurangan |
|---|---|---|---|---|---|
| Electron | JS/TS + HTML | Win/Mac/Linux | ~150 MB+ | Ekosistem npm besar, mudah | Berat RAM, Chromium bundled |
| Tauri | JS/TS + Rust | Win/Mac/Linux/Mobile | <10 MB | Ringan, aman, fast | Perlu Rust untuk native code |
| Flutter Desktop | Dart | Win/Mac/Linux/Mobile | ~30 MB | Share kode mobile | Dart ecosystem lebih kecil |
| .NET MAUI | C# | Win/Mac/Mobile | ~20 MB | Integrasi Microsoft stack | macOS/Linux support terbatas |
Rumus. Untuk MVP aplikasi desktop di Indonesia (POS, akuntansi, inventory): pilih Electron jika tim sudah web developer. Pilih Tauri jika performa dan ukuran file kritikal. Pilih .NET MAUI jika klien adalah enterprise dengan Windows dan Active Directory.
Aplikasi Mobile
Indonesia adalah negara mobile-first: lebih dari 355 juta nomor SIM aktif, penetrasi smartphone di atas 70%, dan mayoritas transaksi digital dilakukan lewat aplikasi mobile — dari DANA, GoPay, hingga Tokopedia. Bagi developer yang ingin membangun produk konsumen atau fieldwork tools untuk tim lapangan, mobile adalah platform utama, bukan pilihan tambahan. Pertanyaannya bukan apakah perlu mobile, tapi teknologi apa yang paling masuk akal untuk tim dan pasar Anda.
React Native: JavaScript untuk Android dan iOS
React Native memungkinkan developer JavaScript/TypeScript membangun aplikasi Android dan iOS dari satu codebase. Berbeda dengan WebView-based framework seperti Ionic, React Native mengkompilasi ke komponen native sungguhan — bukan HTML yang dibungkus. Tampilan dan performa terasa lebih native dibanding WebView, meski tidak setara dengan app native murni untuk kasus komputasi berat.
React Native sangat cocok untuk: aplikasi CRUD dengan banyak form, aplikasi yang butuh state management kompleks (Redux/Zustand), tim yang sudah mahir React untuk web dan ingin ekspansi ke mobile. Ekosistem library sangat besar: React Navigation, Expo (yang menyederhanakan build pipeline drastis), dan ribuan library komunitas.
// App.tsx — React Native dengan Expo + AI Integration
import React, { useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity,
ScrollView, StyleSheet, ActivityIndicator, Alert
} from 'react-native';
import { Camera, CameraType } from 'expo-camera';
import * as ImagePicker from 'expo-image-picker';
export default function AIReceiptScanner() {
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState(null);
async function scanReceipt() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Izin Diperlukan', 'Aplikasi butuh akses galeri foto');
return;
}
const image = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
base64: true, // dapatkan base64 untuk dikirim ke AI
quality: 0.8
});
if (!image.canceled) {
setScanning(true);
try {
// Kirim ke OpenAI Vision API untuk ekstrak data struk
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4o',
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'Ekstrak data dari struk ini: nama toko, tanggal, total, item pembelian. Balas JSON.' },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${image.assets[0].base64}` } }
]
}],
max_tokens: 500
})
});
const data = await response.json();
setResult(JSON.parse(data.choices[0].message.content));
} catch (err) {
Alert.alert('Error', 'Gagal scan struk: ' + err.message);
} finally {
setScanning(false);
}
}
}
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>AI Struk Scanner</Text>
<TouchableOpacity style={styles.button} onPress={scanReceipt}>
<Text style={styles.buttonText}>Scan Struk Belanja</Text>
</TouchableOpacity>
{scanning && <ActivityIndicator size="large" color="#1e3a8a" />}
{result && (
<View style={styles.result}>
<Text>Toko: {result.toko}</Text>
<Text>Total: Rp {result.total?.toLocaleString('id-ID')}</Text>
</View>
)}
</ScrollView>
);
}
Flutter: Dart untuk Semua Platform
Flutter dari Google menggunakan Dart dan engine rendering sendiri (Skia/Impeller) — artinya tampilan 100% konsisten di semua platform karena Flutter tidak menggunakan widget native OS, melainkan menggambar semua UI sendiri. Hasilnya: tampilan pixel-perfect di Android, iOS, web, desktop Windows/macOS/Linux dari satu codebase Dart.
Flutter sangat baik untuk: aplikasi dengan UI custom yang kompleks (animasi, dashboard visual, game casual), tim yang mau invest waktu belajar Dart (kurva awal cukup landai), dan startup yang butuh Android + iOS + Web + Desktop sekaligus tanpa tim terpisah.
Kotlin Multiplatform, Native Android, dan Native iOS
Kotlin Multiplatform (KMP) mengambil pendekatan berbeda: share business logic dan data layer dalam Kotlin, tapi biarkan UI tetap native (Jetpack Compose untuk Android, SwiftUI untuk iOS). Ini menghasilkan performa dan UX terbaik, tapi membutuhkan developer yang mahir Kotlin dan Swift sekaligus, atau dua tim terpisah.
Native Android dengan Kotlin + Jetpack Compose adalah pilihan ketika performa, akses hardware (kamera, GPS, NFC, Bluetooth), atau UX Android-spesifik sangat kritikal. Native iOS dengan Swift + SwiftUI untuk kasus serupa di ekosistem Apple.
| Framework | Bahasa | Platform | Performa | Ukuran Tim | Terbaik Untuk |
|---|---|---|---|---|---|
| React Native (Expo) | TypeScript | Android + iOS | Baik | Solo / kecil | CRUD app, startup MVP cepat |
| Flutter | Dart | Android/iOS/Web/Desktop | Sangat baik | Solo / kecil | UI kompleks, semua platform |
| Kotlin Multiplatform | Kotlin + Swift | Android + iOS | Native | Sedang | Share logic, UI native |
| Native Android | Kotlin | Android saja | Terbaik | Sedang | Akses hardware penuh |
| Native iOS | Swift | iOS saja | Terbaik | Sedang | App Store premium |
Mobile + AI: On-Device dan Cloud
Integrasi AI di mobile punya dua jalur: cloud AI (panggil API eksternal) dan on-device AI (model berjalan di perangkat). Cloud AI lebih mudah dan model lebih powerful, tapi butuh internet dan ada biaya per request. On-device AI bekerja offline, privasi lebih baik, dan tidak ada latensi jaringan — ideal untuk keyboard AI, kamera AI, dan voice AI yang harus instan.
Google ML Kit menyediakan model on-device siap pakai: text recognition (OCR), face detection, barcode scanning, language identification. Apple Core ML untuk iOS. TensorFlow Lite dan ONNX Runtime untuk model custom di kedua platform.
Monetisasi Mobile App
Tiga model utama: In-App Purchase (IAP) untuk unlock fitur atau konten satu kali; subscription via Apple StoreKit 2 atau Google Play Billing (Google/Apple ambil 15–30%); dan iklan via Google AdMob. Untuk aplikasi B2B (field sales, inventory, kasir), model license per seat atau bulanan yang dibayar via transfer bank/payment gateway lebih umum daripada IAP.
Awas. Apple dan Google mengambil 30% dari semua transaksi IAP dan subscription yang pertama dibayar lewat platform mereka — ini non-negotiable. Untuk bisnis B2B dengan nilai tinggi, arahkan pengguna untuk berlangganan lewat website Anda, bukan lewat App Store.
Aplikasi API-only / Backend Service
API sebagai produk adalah model bisnis yang sangat efisien: Anda membangun satu set endpoint, dan ratusan atau ribuan aplikasi lain bisa mengonsumsinya. Tidak ada frontend yang perlu di-maintain, tidak ada app store yang perlu diurus — hanya dokumentasi yang bagus, uptime yang tinggi, dan harga yang kompetitif. Stripe, Twilio, OpenAI, Midtrans, dan RajaOngkir semuanya adalah bisnis API-first. Di Indonesia, ada ruang besar untuk API data lokal: data kependudukan, validasi NPWP, cek alamat, API cuaca lokal, API BPJS, dan ratusan kategori lainnya yang belum tersentuh dengan kualitas yang baik.
REST API sebagai Produk
REST API yang dijual sebagai produk berbeda dari REST API internal. Ia harus punya: dokumentasi interaktif (Swagger/OpenAPI), SDK client di beberapa bahasa, API key management dengan rate limiting per tier, webhook untuk event notification, dan SLA yang jelas. Pengguna API adalah developer — mereka sangat sensitif terhadap konsistensi response format, penanganan error yang informatif, dan backward compatibility.
# OpenAPI 3.0 spec untuk API produk — potongan
openapi: 3.0.3
info:
title: NomorPintar API — Validasi Data Indonesia
version: 1.2.0
description: API untuk validasi NIK, NPWP, nomor HP, kode pos Indonesia
servers:
- url: https://api.nomorpintar.id/v1
security:
- ApiKeyAuth: []
components:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
schemas:
ValidationResult:
type: object
properties:
valid: { type: boolean }
message: { type: string }
data: { type: object }
paths:
/validate/nik:
post:
summary: Validasi Nomor Induk Kependudukan
requestBody:
content:
application/json:
schema:
type: object
required: [nik]
properties:
nik:
type: string
example: "3273012506980001"
responses:
'200':
description: Hasil validasi NIK
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationResult'
example:
valid: true
message: "NIK valid"
data:
provinsi: "Jawa Barat"
kabupaten: "Kota Bandung"
tanggal_lahir: "1998-06-25"
jenis_kelamin: "Laki-laki"
'401':
description: API key tidak valid atau habis kuota
GraphQL: Fleksibilitas untuk Client Beragam
GraphQL memungkinkan client meminta tepat data yang dibutuhkan — tidak lebih, tidak kurang. Ini sangat berguna ketika Anda punya beberapa client dengan kebutuhan berbeda: mobile app butuh data ringkas, web dashboard butuh data lengkap. Dengan REST, Anda butuh multiple endpoint atau parameter opsional yang rumit; dengan GraphQL, satu endpoint menangani semua kebutuhan lewat query yang ditulis client.
# GraphQL query — client mobile minta data minimal
query GetOrderList($userId: ID!) {
orders(userId: $userId, limit: 10) {
id
status
total
createdAt
}
}
# GraphQL query — dashboard minta data lengkap untuk analitik
query GetOrderDetail($orderId: ID!) {
order(id: $orderId) {
id
status
total
createdAt
updatedAt
customer {
id
name
email
phone
}
items {
productId
productName
quantity
price
discount
}
shipping {
courier
trackingNumber
estimatedDelivery
address { street, city, province, postalCode }
}
payments {
method
amount
paidAt
reference
}
}
}
gRPC: Performa Tinggi untuk Komunikasi Antar Layanan
gRPC menggunakan Protocol Buffers (protobuf) untuk serialisasi data — jauh lebih efisien dari JSON — dan HTTP/2 untuk transport. Performa 5–10x lebih cepat dari REST JSON untuk volume tinggi. Ideal untuk komunikasi antar microservice internal, bukan untuk API publik yang dikonsumsi browser (karena browser tidak mendukung HTTP/2 streaming gRPC secara native tanpa proxy).
// payment.proto — definisi gRPC service
syntax = "proto3";
package payment.v1;
service PaymentService {
rpc ProcessPayment (PaymentRequest) returns (PaymentResponse);
rpc CheckStatus (StatusRequest) returns (StatusResponse);
rpc StreamTransactions (StreamRequest) returns (stream Transaction);
}
message PaymentRequest {
string order_id = 1;
int64 amount = 2; // dalam sen/rupiah
string currency = 3; // "IDR"
string method = 4; // "qris", "va_bca", "gopay"
string customer_id = 5;
}
message PaymentResponse {
string payment_id = 1;
string status = 2;
string payment_url = 3; // URL redirect ke payment gateway
int64 expired_at = 4; // unix timestamp
}
WebSocket API sebagai Produk dan Jenis API yang Laku Dijual
WebSocket API cocok dijual untuk use case real-time: live price feed (forex, kripto, saham), notifikasi event, stream data IoT sensor. Pelanggan berlangganan channel tertentu dan menerima push data tanpa polling. Model bisnis: bayar per koneksi aktif atau per pesan yang dikirim.
Kategori API yang memiliki pasar jelas di Indonesia: Data API (kurs mata uang, harga komoditas, data demografi, API data desa/kelurahan); AI API (OCR dokumen Indonesia, terjemahan bahasa daerah, NLP Bahasa Indonesia, deteksi bahasa gaul/slang); Automation API (kirim WhatsApp, kirim SMS blast, generate sertifikat PDF, generate laporan Excel). Semua kategori ini punya demand yang bisa divalidasi sebelum membangun.
Rumus. API produk yang baik: satu endpoint jelas satu fungsi, response time < 500ms untuk p99, error message dalam Bahasa Indonesia (jika target developer Indonesia), dan free tier 100–1000 request/hari untuk akuisisi developer. Monetisasi lewat tiering: Starter (gratis), Professional (Rp 299.000/bulan), Enterprise (custom).
Aplikasi CLI & Terminal
Aplikasi CLI (Command-Line Interface) adalah kategori yang secara konsisten underestimated oleh developer yang selalu berpikir dalam paradigma GUI. Kenyataannya, tool CLI yang tepat sasaran bisa menjadi produk yang sangat sukses: GitHub CLI, AWS CLI, Vercel CLI, Railway CLI, dan Stripe CLI semuanya adalah contoh CLI yang digunakan jutaan developer setiap hari. Di ekosistem DevOps dan developer tools Indonesia yang terus berkembang, ada peluang nyata untuk CLI yang membantu proses deployment ke hosting lokal, generate boilerplate, atau mengotomatisasi workflow spesifik industri lokal.
Node.js CLI dengan Commander dan Yargs
Node.js adalah pilihan populer untuk CLI karena ekosistem npm yang besar dan kemudahan distribusi via npm install -g. Dua library paling populer adalah commander.js (lebih minimalis, opinionated) dan yargs (lebih fleksibel, built-in help generation otomatis).
#!/usr/bin/env node
// cli.js — Node CLI dengan Commander.js
import { Command } from 'commander';
import { execSync } from 'child_process';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs';
import path from 'path';
const program = new Command();
program
.name('deploy-indo')
.description('CLI deploy aplikasi ke VPS Indonesia (Niagahoster/IDCloudHost)')
.version('1.0.0');
// Sub-command: deploy
program
.command('deploy')
.description('Deploy aplikasi ke server')
.requiredOption('-s, --server <host>', 'IP atau hostname server')
.option('-u, --user <user>', 'SSH user', 'ubuntu')
.option('-p, --port <port>', 'SSH port', '22')
.option('--branch <branch>', 'Git branch', 'main')
.action(async (options) => {
const spinner = ora('Menghubungkan ke server...').start();
try {
// Pull latest code via SSH
spinner.text = 'Pulling latest code...';
execSync(
`ssh -p ${options.port} ${options.user}@${options.server} "cd /var/www/app && git pull origin ${options.branch}"`,
{ stdio: 'pipe' }
);
// Restart PM2
spinner.text = 'Restart aplikasi dengan PM2...';
execSync(
`ssh -p ${options.port} ${options.user}@${options.server} "pm2 restart app"`,
{ stdio: 'pipe' }
);
spinner.succeed(chalk.green('Deploy berhasil!'));
console.log(chalk.blue(`Server: ${options.server}`));
console.log(chalk.blue(`Branch: ${options.branch}`));
} catch (err) {
spinner.fail(chalk.red('Deploy gagal: ' + err.message));
process.exit(1);
}
});
// Sub-command: generate boilerplate
program
.command('init <project-name>')
.description('Generate boilerplate project baru')
.option('--template <template>', 'Template: express|fastify|hono', 'express')
.action((projectName, options) => {
const dir = path.join(process.cwd(), projectName);
if (fs.existsSync(dir)) {
console.error(chalk.red(`Folder ${projectName} sudah ada!`));
process.exit(1);
}
fs.mkdirSync(dir, { recursive: true });
console.log(chalk.green(`✓ Project ${projectName} dibuat dengan template ${options.template}`));
});
program.parse();
Python CLI dengan Click dan Typer
Click adalah library CLI Python yang paling mature dan digunakan di banyak tool populer (Flask CLI, dbt CLI). Typer adalah wrapper di atas Click yang memanfaatkan type hints Python untuk generate CLI secara otomatis — sangat produktif untuk developer yang suka typing.
#!/usr/bin/env python3
# cli.py — Python CLI dengan Typer
import typer
import httpx
import json
import rich
from rich.table import Table
from rich.console import Console
from rich import print as rprint
from typing import Optional
from enum import Enum
app = typer.Typer(name="rajaongkir-cli", help="CLI untuk cek ongkos kirim via RajaOngkir API")
console = Console()
class Courier(str, Enum):
jne = "jne"
pos = "pos"
tiki = "tiki"
sicepat = "sicepat"
jnt = "jnt"
@app.command()
def cek_ongkir(
asal: int = typer.Option(..., "--asal", "-a", help="ID kota asal"),
tujuan: int = typer.Option(..., "--tujuan", "-t", help="ID kota tujuan"),
berat: int = typer.Option(..., "--berat", "-b", help="Berat paket dalam gram"),
kurir: Courier = typer.Option(Courier.jne, "--kurir", "-k", help="Nama kurir"),
api_key: str = typer.Option(..., envvar="RAJAONGKIR_API_KEY", help="API key RajaOngkir")
):
"""Cek ongkos kirim antara dua kota Indonesia."""
with console.status(f"Mengambil data ongkir {kurir.value.upper()}..."):
resp = httpx.post(
"https://api.rajaongkir.com/starter/cost",
headers={"key": api_key},
data={"origin": asal, "destination": tujuan, "weight": berat, "courier": kurir.value}
)
data = resp.json()
if resp.status_code != 200 or data.get("rajaongkir", {}).get("status", {}).get("code") != 200:
rprint(f"[red]Error:[/red] {data}")
raise typer.Exit(1)
services = data["rajaongkir"]["results"][0]["costs"]
table = Table(title=f"Ongkir {kurir.value.upper()} — {berat}g")
table.add_column("Layanan", style="cyan")
table.add_column("Estimasi", style="green")
table.add_column("Harga (Rp)", style="yellow", justify="right")
for svc in services:
for cost in svc["cost"]:
table.add_row(
svc["service"],
cost.get("etd", "-") + " hari",
f"{cost['value']:,}"
)
console.print(table)
if __name__ == "__main__":
app()
Go CLI dengan Cobra
Go adalah pilihan terbaik untuk CLI yang perlu distribusi sebagai single binary tanpa runtime dependency. Binary Go berjalan di Windows, macOS, Linux tanpa perlu install Node.js atau Python — sangat penting untuk tool DevOps yang di-copy ke berbagai server. Cobra adalah framework CLI Go yang digunakan kubectl, Docker CLI, dan GitHub CLI.
// main.go — Go CLI dengan Cobra
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var rootCmd = &cobra.Command{
Use: "dbbackup",
Short: "Tool backup database PostgreSQL/MySQL otomatis",
Long: `CLI untuk backup, compress, encrypt, dan upload database ke cloud storage.`,
}
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Jalankan backup sekarang",
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
db, _ := cmd.Flags().GetString("db")
out, _ := cmd.Flags().GetString("output")
fmt.Printf("Backup database '%s' dari host '%s'...\n", db, host)
// Jalankan pg_dump
dumpCmd := fmt.Sprintf(
"pg_dump -h %s -U %s %s | gzip > %s/%s.sql.gz",
host, viper.GetString("db.user"), db, out, db,
)
return execShell(dumpCmd)
},
}
func init() {
backupCmd.Flags().StringP("host", "H", "localhost", "Database host")
backupCmd.Flags().StringP("db", "d", "", "Nama database (wajib)")
backupCmd.Flags().StringP("output", "o", "/tmp/backup", "Direktori output")
backupCmd.MarkFlagRequired("db")
rootCmd.AddCommand(backupCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func execShell(cmd string) error {
// implementasi exec.Command
return nil
}
CLI yang Laku: Kategori dan Monetisasi
CLI yang sukses secara komersial umumnya jatuh di kategori: dev tools (scaffold, lint, generate, migrate), automation (cron task, bulk operation, data transform), dan deployment (deploy, rollback, scale). Distribusi via npm, pip, homebrew, atau download binary langsung. Monetisasi: open-source dengan tier pro yang membutuhkan API key berbayar, atau lisensi per seat untuk enterprise.
Tips. Untuk CLI yang akan digunakan di CI/CD pipeline, pastikan ada mode
--non-interactiveatau--yesyang skip semua prompt konfirmasi. Exit code harus konsisten: 0 sukses, 1 error umum, 2 invalid usage. JSON output via flag--jsonuntuk integrasi dengan tool lain.
Aplikasi IoT & Embedded
Internet of Things (IoT) adalah kategori yang menjembatani dunia fisik dengan infrastruktur digital: sensor suhu di gudang pendingin yang mengirim alert ketika temperatur naik di atas ambang batas, meter listrik pintar yang melaporkan konsumsi real-time ke cloud, atau kamera absensi yang mengenali wajah karyawan dan mencatat kehadiran otomatis. Di Indonesia, IoT sedang berkembang pesat untuk pertanian pintar (smart farming), smart city di IKN, pemantauan kualitas air, hingga sistem keamanan UMKM skala kecil. Developer yang menguasai embedded programming plus cloud backend punya kombinasi skill yang sangat langka dan sangat dicari.
ESP32 dan ESP8266: Fondasi IoT Terjangkau
ESP8266 dan ESP32 dari Espressif Systems adalah mikrokontroler dengan Wi-Fi built-in yang harganya mulai dari Rp 20.000–80.000 per unit. ESP8266 lebih sederhana dan hemat daya untuk aplikasi sensor dasar. ESP32 lebih powerful: dual-core processor, Bluetooth + BLE + Wi-Fi, lebih banyak GPIO, dan ADC (Analog-to-Digital Converter) yang lebih baik untuk membaca sensor analog.
Keduanya bisa diprogram menggunakan Arduino IDE dengan bahasa C/C++, atau menggunakan MicroPython/CircuitPython untuk mereka yang lebih nyaman dengan Python. PlatformIO adalah alternatif IDE yang lebih profesional dengan dependency management dan CI/CD support.
// sensor_suhu.ino — ESP32 + DHT22 + MQTT + WiFi
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
#include <ArduinoJson.h>
// Konfigurasi WiFi
const char* WIFI_SSID = "NamaWiFi";
const char* WIFI_PASSWORD = "PasswordWiFi";
// Konfigurasi MQTT Broker (bisa HiveMQ Cloud, EMQX, atau self-hosted Mosquitto)
const char* MQTT_SERVER = "broker.emqx.io";
const int MQTT_PORT = 1883;
const char* MQTT_TOPIC = "gudang/sensor/suhu-1";
const char* DEVICE_ID = "ESP32-GUDANG-001";
// Pin dan tipe sensor DHT
#define DHT_PIN 4
#define DHT_TYPE DHT22
DHT dht(DHT_PIN, DHT_TYPE);
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
void connectWiFi() {
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Menghubungkan ke WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi terhubung! IP: " + WiFi.localIP().toString());
}
void connectMQTT() {
while (!mqttClient.connected()) {
Serial.print("Koneksi ke MQTT broker...");
if (mqttClient.connect(DEVICE_ID)) {
Serial.println("Terhubung!");
// Subscribe ke topic perintah (untuk remote control)
mqttClient.subscribe("gudang/perintah/sensor-1");
} else {
Serial.printf("Gagal, rc=%d. Coba lagi 5 detik\n", mqttClient.state());
delay(5000);
}
}
}
// Callback saat menerima pesan dari broker
void onMQTTMessage(char* topic, byte* payload, unsigned int length) {
String msg = String((char*)payload, length);
Serial.printf("Pesan diterima [%s]: %s\n", topic, msg.c_str());
// Parse perintah JSON dan eksekusi
StaticJsonDocument<200> doc;
if (deserializeJson(doc, msg) == DeserializationError::Ok) {
const char* command = doc["command"];
if (strcmp(command, "reset") == 0) ESP.restart();
}
}
void setup() {
Serial.begin(115200);
dht.begin();
connectWiFi();
mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
mqttClient.setCallback(onMQTTMessage);
}
void loop() {
if (!mqttClient.connected()) connectMQTT();
mqttClient.loop();
static unsigned long lastPublish = 0;
if (millis() - lastPublish >= 30000) { // Kirim setiap 30 detik
lastPublish = millis();
float suhu = dht.readTemperature();
float kelembaban = dht.readHumidity();
if (isnan(suhu) || isnan(kelembaban)) {
Serial.println("Gagal baca sensor DHT22!");
return;
}
// Buat payload JSON
StaticJsonDocument<200> payload;
payload["device_id"] = DEVICE_ID;
payload["suhu"] = suhu;
payload["kelembaban"] = kelembaban;
payload["timestamp"] = millis();
char payloadStr[200];
serializeJson(payload, payloadStr);
// Publish ke MQTT broker
if (mqttClient.publish(MQTT_TOPIC, payloadStr)) {
Serial.printf("Terkirim: suhu=%.1f C, kelembaban=%.1f%%\n", suhu, kelembaban);
}
// Alert jika suhu di atas 30 derajat (ambang batas gudang)
if (suhu > 30.0) {
mqttClient.publish("gudang/alert", "{\"type\":\"suhu_tinggi\",\"value\":" + String(suhu) + "}");
}
}
}
MQTT Broker dan Arsitektur IoT Cloud
MQTT (Message Queuing Telemetry Transport) adalah protokol messaging ringan yang dirancang khusus untuk IoT — overhead sangat kecil (2 byte header minimum), cocok untuk perangkat dengan baterai atau koneksi tidak stabil. MQTT menggunakan model publish/subscribe: perangkat publish ke topic, subscriber (bisa cloud backend, dashboard, atau perangkat lain) menerima pesan secara real-time.
Pilihan MQTT broker: Mosquitto (open-source, self-hosted di VPS), EMQX (enterprise-grade, cloud dan self-hosted), HiveMQ Cloud (managed, free tier 100 koneksi), AWS IoT Core, Google Cloud IoT Core. Untuk MVP Indonesia, EMQX free tier atau Mosquitto di VPS Rp 30.000/bulan sudah cukup untuk ratusan perangkat.
# Python backend — subscribe MQTT, simpan ke PostgreSQL, kirim alert
import asyncio
import json
import asyncpg
from asyncio_mqtt import Client as MQTTClient
import httpx
from datetime import datetime
MQTT_BROKER = "broker.emqx.io"
DB_URL = "postgresql://user:pass@localhost/iot_db"
WA_WEBHOOK = "https://api.fonnte.com/send" # WhatsApp gateway Indonesia
async def save_reading(pool, data: dict):
"""Simpan pembacaan sensor ke database."""
await pool.execute("""
INSERT INTO sensor_readings (device_id, suhu, kelembaban, created_at)
VALUES ($1, $2, $3, $4)
""", data["device_id"], data["suhu"], data["kelembaban"], datetime.utcnow())
async def send_wa_alert(device_id: str, suhu: float):
"""Kirim alert WhatsApp ke admin gudang."""
async with httpx.AsyncClient() as client:
await client.post(WA_WEBHOOK, json={
"target": "628123456789",
"message": f"ALERT: Suhu gudang {device_id} mencapai {suhu:.1f}°C! Segera periksa."
}, headers={"Authorization": "Bearer TOKEN_FONNTE"})
async def main():
pool = await asyncpg.create_pool(DB_URL, min_size=2, max_size=10)
async with MQTTClient(MQTT_BROKER) as client:
await client.subscribe("gudang/#") # subscribe semua topic gudang
async with client.messages() as messages:
async for message in messages:
try:
payload = json.loads(message.payload)
await save_reading(pool, payload)
if payload.get("suhu", 0) > 30.0:
await send_wa_alert(payload["device_id"], payload["suhu"])
except Exception as e:
print(f"Error proses pesan: {e}")
asyncio.run(main())
Edge Computing dan IoT + AI
Edge computing memindahkan sebagian prosesan dari cloud ke perangkat atau gateway lokal — mengurangi latensi, menghemat bandwidth, dan memungkinkan operasi offline. Raspberry Pi 4 atau Jetson Nano bisa berperan sebagai edge gateway: mengumpulkan data dari beberapa ESP32 via Wi-Fi/MQTT lokal, melakukan preprocessing, lalu mengirim data terfilter ke cloud.
IoT + AI membuka kategori produk baru: kamera keamanan dengan deteksi wajah on-device (ESP32-CAM + model TFLite), sensor pertanian yang prediksi panen berdasarkan data historis, sistem monitoring mesin pabrik yang deteksi anomali getaran sebelum kerusakan terjadi. Model TensorFlow Lite bisa berjalan di ESP32 untuk inferensi sederhana; model yang lebih besar butuh Raspberry Pi atau Jetson Nano.
Monetisasi IoT: Hardware + Subscription Cloud
Model bisnis IoT paling umum adalah hardware + subscription: jual perangkat (margin 30–50% dari harga produksi), lalu charge subscription bulanan untuk akses dashboard cloud, penyimpanan data historis, alert, dan laporan. Ini model yang digunakan Nest (Google), Xiaomi, dan puluhan startup smart home. Di konteks Indonesia: jual kit sensor Rp 500.000 + langganan dashboard Rp 99.000/bulan per lokasi pemantauan.
Awas. Jangan simpan API key atau password Wi-Fi langsung di firmware yang di-flash ke perangkat yang dijual. Gunakan provisioning: perangkat di-boot dalam access point mode, pengguna connect via browser lokal dan isi konfigurasi, lalu konfigurasi disimpan di EEPROM/SPIFFS secara terenkripsi.
Aplikasi AI & Machine Learning
Dalam dua tahun terakhir, AI berpindah dari topik penelitian ke alat produksi yang bisa diintegrasikan developer mana pun ke produknya dalam hitungan jam. OpenAI API, Anthropic Claude API, Google Gemini API, dan deretan model open-source seperti LLaMA, Mistral, dan Qwen memberikan akses ke kecerdasan level state-of-the-art tanpa perlu training model dari nol, tanpa GPU server, dan tanpa tim data scientist. Ini perubahan struktural, bukan tren sementara. Developer yang memahami cara mengintegrasikan, meng-orchestrate, dan memonetisasi kemampuan AI akan memiliki keunggulan kompetitif yang sangat nyata dalam membangun produk digital ke depan.
OpenAI API: Fondasi Integrasi AI
OpenAI API adalah entry point termudah ke kemampuan AI generatif. Dengan satu HTTP request, Anda mendapatkan akses ke GPT-4o (multimodal: teks + gambar + audio), GPT-4o-mini (lebih murah, lebih cepat untuk task sederhana), DALL-E 3 (generasi gambar), Whisper (speech-to-text), dan TTS (text-to-speech).
Pricing berbasis token: GPT-4o-mini sekitar $0.15 per juta input token dan $0.60 per juta output token (per Juni 2024). Untuk aplikasi chatbot sederhana dengan 100 pengguna aktif harian yang masing-masing mengirim 10 pesan pendek, biaya API di bawah $5/bulan — sangat terjangkau untuk MVP.
# Python — OpenAI API integration lengkap
import openai
from openai import OpenAI
import base64
import json
from pathlib import Path
client = OpenAI(api_key="sk-...") # atau baca dari env: os.environ["OPENAI_API_KEY"]
# 1. Chat completion dasar
def chat(messages: list[dict], model="gpt-4o-mini") -> str:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=0.7,
max_tokens=1024
)
return response.choices[0].message.content
# 2. Streaming response (lebih responsif untuk UX)
def chat_stream(messages: list[dict]):
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
stream=True
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield delta # yield tiap token untuk streaming ke frontend
# 3. Vision — analisis gambar
def analyze_image(image_path: str, question: str) -> str:
image_data = base64.b64encode(Path(image_path).read_bytes()).decode()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": question},
{"type": "image_url", "image_url": {
"url": f"data:image/jpeg;base64,{image_data}",
"detail": "high" # high/low/auto
}}
]
}]
)
return response.choices[0].message.content
# 4. Structured output dengan JSON mode
def extract_invoice_data(text: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Ekstrak data invoice. Balas JSON saja."},
{"role": "user", "content": text}
],
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
# 5. Function calling / Tool use
tools = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Cek status pesanan berdasarkan nomor order",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Nomor order, contoh: ORD-2024-001"}
},
"required": ["order_id"]
}
}
}]
def chatbot_with_tools(user_message: str, history: list) -> str:
messages = history + [{"role": "user", "content": user_message}]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = response.choices[0].message
if msg.tool_calls:
# Model ingin memanggil fungsi — eksekusi dan kirim hasilnya kembali
tool_call = msg.tool_calls[0]
fn_args = json.loads(tool_call.function.arguments)
order_data = fetch_order_from_db(fn_args["order_id"]) # fungsi DB Anda
messages.append(msg)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(order_data)
})
# Request kedua: model buat respons berdasarkan data tool
final = client.chat.completions.create(model="gpt-4o-mini", messages=messages)
return final.choices[0].message.content
return msg.content
Local LLM dengan Ollama dan llama.cpp
Ollama adalah cara termudah menjalankan LLM open-source secara lokal di laptop atau VPS. Dengan satu perintah ollama run llama3, Anda punya model Llama 3 yang berjalan dengan REST API kompatibel OpenAI di http://localhost:11434. Ini berarti kode yang sama yang memanggil OpenAI API bisa dialihkan ke Ollama hanya dengan mengubah base URL.
Keuntungan local LLM: privasi data (tidak ada data keluar dari server Anda), tidak ada biaya per token, bisa di-fine-tune untuk domain spesifik. Kekurangan: butuh hardware yang cukup (GPU atau CPU yang kuat), kualitas model open-source umumnya di bawah GPT-4o untuk task kompleks.
# Python — menggunakan Ollama dengan interface kompatibel OpenAI
from openai import OpenAI
# Arahkan ke Ollama lokal — kode sama persis dengan OpenAI
ollama_client = OpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama" # placeholder, Ollama tidak butuh API key nyata
)
def local_chat(prompt: str, model="llama3.1:8b") -> str:
response = ollama_client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
# Bisa juga pakai library ollama langsung untuk fitur lebih
import ollama
def local_chat_with_system(system: str, user: str):
stream = ollama.chat(
model="mistral:7b",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
stream=True
)
for chunk in stream:
print(chunk["message"]["content"], end="", flush=True)
RAG: Retrieval-Augmented Generation
RAG adalah teknik yang memungkinkan LLM menjawab pertanyaan berdasarkan dokumen Anda sendiri, bukan hanya dari training data. Alurnya: (1) chunk dan embed dokumen Anda ke vector database; (2) ketika ada pertanyaan, cari chunk yang paling relevan via similarity search; (3) masukkan chunk tersebut ke prompt sebagai konteks; (4) model menjawab berdasarkan konteks itu.
Ini adalah fondasi dari: chatbot yang bisa menjawab pertanyaan tentang dokumentasi produk Anda, sistem FAQ otomatis untuk BPOM/BPJS/peraturan daerah, atau asisten hukum yang tahu isi kontrak spesifik perusahaan.
# Python — RAG sederhana dengan ChromaDB dan OpenAI
import chromadb
from chromadb.utils import embedding_functions
from openai import OpenAI
import hashlib
openai_client = OpenAI()
chroma_client = chromadb.PersistentClient(path="./vectordb")
# Gunakan OpenAI embedding function
ef = embedding_functions.OpenAIEmbeddingFunction(
api_key="sk-...",
model_name="text-embedding-3-small"
)
# Buat atau load collection
collection = chroma_client.get_or_create_collection(
name="dokumen_perusahaan",
embedding_function=ef
)
def ingest_document(text: str, source: str, chunk_size: int = 500):
"""Potong dokumen jadi chunks dan simpan ke vector DB."""
chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
ids = [hashlib.md5(f"{source}:{i}".encode()).hexdigest() for i in range(len(chunks))]
collection.upsert(
documents=chunks,
ids=ids,
metadatas=[{"source": source, "chunk": i} for i in range(len(chunks))]
)
print(f"Ingested {len(chunks)} chunks dari {source}")
def rag_query(question: str, top_k: int = 3) -> str:
"""Jawab pertanyaan berdasarkan dokumen yang di-ingest."""
# 1. Cari dokumen relevan
results = collection.query(query_texts=[question], n_results=top_k)
context = "\n\n---\n\n".join(results["documents"][0])
# 2. Buat prompt dengan konteks
messages = [
{
"role": "system",
"content": (
"Kamu adalah asisten yang menjawab pertanyaan berdasarkan dokumen yang diberikan. "
"Jika jawabannya tidak ada di dokumen, katakan 'Informasi ini tidak tersedia di dokumen kami.'"
)
},
{
"role": "user",
"content": f"Dokumen referensi:\n\n{context}\n\nPertanyaan: {question}"
}
]
# 3. Generate jawaban
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
return response.choices[0].message.content
# Contoh penggunaan
ingest_document(open("kebijakan_privasi.txt").read(), source="kebijakan_privasi")
ingest_document(open("panduan_pengguna.txt").read(), source="panduan_pengguna")
answer = rag_query("Bagaimana cara reset password?")
print(answer)
AI Agent dengan Tool Use
AI Agent adalah sistem di mana LLM bisa mengambil tindakan, bukan hanya menghasilkan teks: browsing web, menjalankan kode, memanggil API, membaca/menulis file, dan memanggil tool lain. Framework populer: LangChain (Python, ekosistem besar), CrewAI (multi-agent dengan peran spesifik), LlamaIndex (fokus RAG dan data), AutoGen dari Microsoft (conversation antara beberapa agent).
// JavaScript/TypeScript — AI Agent sederhana dengan Vercel AI SDK
import { openai } from '@ai-sdk/openai';
import { generateText, tool } from 'ai';
import { z } from 'zod';
import axios from 'axios';
// Definisi tools yang bisa digunakan agent
const tools = {
// Tool: cek cuaca
getCuaca: tool({
description: 'Ambil data cuaca untuk kota di Indonesia',
parameters: z.object({
kota: z.string().describe('Nama kota, contoh: Jakarta, Surabaya, Bandung')
}),
execute: async ({ kota }) => {
const resp = await axios.get(`https://api.openweathermap.org/data/2.5/weather`, {
params: { q: `${kota},ID`, appid: process.env.WEATHER_API_KEY, units: 'metric', lang: 'id' }
});
const d = resp.data;
return {
kota: d.name,
suhu: d.main.temp,
kondisi: d.weather[0].description,
kelembaban: d.main.humidity
};
}
}),
// Tool: hitung
kalkulator: tool({
description: 'Hitung ekspresi matematika',
parameters: z.object({
ekspresi: z.string().describe('Ekspresi matematika, contoh: 1250000 * 12 * 0.11')
}),
execute: async ({ ekspresi }) => {
// Evaluasi aman (jangan gunakan eval() di production)
const result = Function('"use strict"; return (' + ekspresi + ')')();
return { ekspresi, hasil: result };
}
}),
// Tool: query database
queryDatabase: tool({
description: 'Ambil data dari database aplikasi',
parameters: z.object({
query_type: z.enum(['total_penjualan', 'produk_terlaris', 'pelanggan_baru']),
periode: z.string().optional()
}),
execute: async ({ query_type, periode }) => {
// Jalankan query yang sudah didefinisikan (BUKAN raw SQL dari user!)
const data = await runSafeQuery(query_type, periode);
return data;
}
})
};
// Jalankan agent dengan multi-step reasoning
async function runAgent(userPrompt: string) {
const { text, steps } = await generateText({
model: openai('gpt-4o-mini'),
system: `Kamu adalah asisten bisnis yang membantu menganalisis data penjualan.
Gunakan tools yang tersedia untuk menjawab pertanyaan pengguna.
Selalu berikan angka dalam format Rupiah untuk nilai uang.`,
prompt: userPrompt,
tools,
maxSteps: 5 // maksimal 5 langkah tool use sebelum generate jawaban final
});
console.log('Langkah yang diambil:', steps.length);
return text;
}
// Contoh: agent otomatis ambil data + kalkulasi + jawab
const jawaban = await runAgent(
"Berapa estimasi pajak PPN 11% dari total penjualan bulan ini?"
);
console.log(jawaban);
Computer Vision dan NLP untuk Produk AI
Computer Vision membuka produk seperti: OCR KTP/SIM/paspor (untuk KYC), deteksi kondisi barang dari foto (untuk platform jual-beli), pengenalan plat nomor kendaraan (untuk parkir otomatis), analisis X-ray/foto medis (untuk health tech). Library: OpenCV, PIL/Pillow, TensorFlow/PyTorch, atau cukup panggil GPT-4o Vision API untuk prototyping cepat.
NLP (Natural Language Processing) dalam Bahasa Indonesia punya tantangan tersendiri: banyak kata serapan, bahasa gaul, singkatan, dan variasi dialek. Library spesifik Indonesia: PySastrawi (stemming), IndoBERT (BERT pre-trained Bahasa Indonesia), inaSpeechSegmenter (segmentasi ucapan). Untuk production, fine-tune model ke domain dan bahasa target Anda.
| Kategori Produk AI | Teknologi Kunci | Model/API | Contoh Produk | Monetisasi |
|---|---|---|---|---|
| Chatbot Bisnis | LLM + RAG + Tool Use | GPT-4o / Claude | CS otomatis, FAQ bot | Per percakapan / per pesan |
| Generator Konten | LLM + Prompt Engineering | GPT-4o-mini | Caption, artikel, iklan | Subscription kredit |
| Analyzer Dokumen | Vision + LLM | GPT-4o / Gemini | OCR KTP, scan invoice | Per dokumen |
| Recommender | Embedding + Similarity | text-embedding-3 | Rekomendasi produk, konten | Per API call |
| Voice AI | STT + LLM + TTS | Whisper + GPT + TTS | Asisten suara, interview AI | Per menit audio |
| Deteksi Anomali | ML klasik / LLM | sklearn / GPT | Fraud detection, QC produk | Per prediksi / SaaS |
Fine-tuning: Mengkhususkan Model untuk Domain Anda
Fine-tuning membuat model generik menjadi spesialis domain — tahu terminologi hukum Indonesia, paham format laporan keuangan standar akuntansi Indonesia, atau bisa menulis caption dalam gaya brand Anda. OpenAI menyediakan fine-tuning API untuk GPT-4o-mini dan GPT-3.5-turbo. Format data: JSONL dengan pasangan {"messages": [{"role":"system",...},{"role":"user",...},{"role":"assistant",...}]}.
Fine-tuning paling worth it ketika: Anda punya banyak contoh format output spesifik yang konsisten, model dasar sering salah untuk domain Anda meski dengan prompt panjang, atau Anda ingin menghemat biaya dengan prompt yang lebih pendek karena instruksi sudah "dibakar" ke model.
# Python — Fine-tuning API OpenAI
import openai
import json
client = openai.OpenAI()
# 1. Upload file training data (format JSONL)
training_file = client.files.create(
file=open("training_data.jsonl", "rb"),
purpose="fine-tune"
)
print("File ID:", training_file.id)
# 2. Mulai fine-tuning job
job = client.fine_tuning.jobs.create(
training_file=training_file.id,
model="gpt-4o-mini-2024-07-18", # base model
hyperparameters={
"n_epochs": 3, # jumlah epoch training
"batch_size": "auto",
"learning_rate_multiplier": "auto"
},
suffix="produk-hukum-id" # nama suffix model hasil
)
print("Job ID:", job.id, "Status:", job.status)
# 3. Cek status (polling atau gunakan webhook)
status = client.fine_tuning.jobs.retrieve(job.id)
print("Model selesai:", status.fine_tuned_model) # mis: ft:gpt-4o-mini:org:produk-hukum-id:abc123
# 4. Gunakan model hasil fine-tuning
fine_tuned_response = client.chat.completions.create(
model=status.fine_tuned_model,
messages=[{"role": "user", "content": "Jelaskan Pasal 1320 KUHPerdata dalam 3 kalimat."}]
)
print(fine_tuned_response.choices[0].message.content)
Membangun Produk AI: Pola Arsitektur
Produk AI yang baik bukan sekedar wrapper tipis di atas OpenAI API — itu bisa ditiru siapa saja dalam satu jam. Nilai tambah sesungguhnya ada di: data training proprietary yang tidak dimiliki kompetitor, integrasi mendalam dengan workflow pengguna (bukan chat terisolasi), fine-tuning untuk domain spesifik, atau UX yang jauh lebih baik dari raw API. Bangun moat di satu dari empat area ini sejak hari pertama.
Inti. Stack AI produk yang proven untuk MVP Indonesia: OpenAI API atau Anthropic Claude untuk LLM (mulai dari yang paling capable), ChromaDB atau pgvector untuk vector storage (gratis, self-hosted), FastAPI untuk backend AI endpoint, React/Next.js untuk frontend streaming, dan Stripe + Xendit untuk monetisasi. Mulai dari yang paling sederhana, ukur usage, baru tambah kompleksitas.
Awas. Jangan expose API key LLM ke frontend. Selalu routing melalui backend Anda sendiri — ini memungkinkan Anda mengontrol rate limiting per user, menyimpan audit log percakapan, menerapkan content filter, dan tidak membocorkan API key senilai jutaan rupiah jika trafik meledak.
Skala, Pengujian & Optimasi
Load Testing
Setiap aplikasi terasa cepat ketika hanya satu orang yang menggunakannya. Kenyataan produksi sangat berbeda: ratusan pengguna masuk bersamaan saat flash sale, ribuan request datang setelah fitur baru viral di media sosial, atau sekadar lonjakan normal di jam makan siang. Tanpa load testing, Anda tidak tahu kapan sistem Anda akan mulai melambat, kapan ia mulai menolak request, dan di komponen mana ia pertama kali menyerah. Load testing adalah satu-satunya cara ilmiah untuk menjawab pertanyaan “berapa banyak pengguna yang bisa dilayani sistem kita sekarang?” — sebelum pengguna nyata yang merasakannya.
Mengapa Load Testing Bukan Opsional
Di Indonesia, lonjakan traffic bersifat tidak linear dan tidak dapat diprediksi: harbolnas 11.11, momen pembukaan pendaftaran CPNS, atau viralnya sebuah produk UMKM di TikTok. Tanpa baseline performa yang terukur, tim engineering hanya bisa menebak apakah sistem akan bertahan. Load testing memberi Anda dua aset kritis: angka kapasitas maksimum (berapa virtual user / VU sebelum error rate >1%) dan identifikasi bottleneck (database query lambat? koneksi pool habis? memory leak?). Kedua aset ini menentukan roadmap scaling yang berbasis data, bukan spekulasi.
Ada tiga jenis pengujian beban yang perlu dipahami secara berbeda. Load test adalah pengujian pada beban normal dan puncak yang diperkirakan — bertujuan memverifikasi bahwa sistem memenuhi SLA. Stress test mendorong sistem melampaui kapasitas yang direncanakan untuk menemukan titik patah. Spike test mensimulasikan lonjakan mendadak seperti traffic flash sale — naik dari 10 VU ke 500 VU dalam hitungan detik. Ketiganya perlu dilakukan secara rutin, bukan hanya sebelum launch.
k6 — Load Testing Modern dengan JavaScript
k6 adalah tool load testing open-source buatan Grafana Labs yang menggunakan JavaScript sebagai bahasa skrip. Keunggulannya: ringan (binary tunggal tanpa JVM), menghasilkan output terstruktur yang mudah diintegrasikan ke CI/CD, dan mendukung berbagai protokol termasuk HTTP/1.1, HTTP/2, WebSocket, dan gRPC. k6 tidak menjalankan browser sungguhan — ia mengirim request HTTP langsung, sehingga sangat efisien untuk pengujian API.
// scripts/load-test-api.js — skrip k6 untuk pengujian API produk
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Metrik custom
const errorRate = new Rate('error_rate');
const loginDuration = new Trend('login_duration');
const produkListDuration = new Trend('produk_list_duration');
// Konfigurasi skenario
export const options = {
scenarios: {
// Fase warm-up: ramp dari 0 ke 50 VU selama 1 menit
ramp_up: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 50 }, // ramp up
{ duration: '3m', target: 50 }, // sustain beban normal
{ duration: '1m', target: 200 }, // ramp ke beban puncak
{ duration: '2m', target: 200 }, // sustain beban puncak
{ duration: '1m', target: 0 }, // ramp down
],
},
},
// Performance budget — test gagal jika dilanggar
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1500'], // 95th percentile <500ms
http_req_failed: ['rate<0.01'], // error rate <1%
error_rate: ['rate<0.01'],
produk_list_duration: ['p(95)<300'], // endpoint list lebih ketat
},
};
// Data test (bisa juga dibaca dari file CSV)
const BASE_URL = __ENV.BASE_URL || 'https://api.tokoku.id';
const TEST_USERS = [
{ email: 'user1@test.com', password: 'Test1234!' },
{ email: 'user2@test.com', password: 'Test1234!' },
{ email: 'user3@test.com', password: 'Test1234!' },
];
export default function () {
// Pilih user secara random
const user = TEST_USERS[Math.floor(Math.random() * TEST_USERS.length)];
// 1. Login
const loginStart = Date.now();
const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
email: user.email,
password: user.password,
}), {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'POST /auth/login' },
});
loginDuration.add(Date.now() - loginStart);
const loginOk = check(loginRes, {
'login status 200': (r) => r.status === 200,
'login ada token': (r) => r.json('data.token') !== undefined,
});
errorRate.add(!loginOk);
if (!loginOk) {
sleep(1);
return; // Jangan lanjut jika login gagal
}
const token = loginRes.json('data.token');
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
sleep(0.5);
// 2. Ambil daftar produk dengan filter
const listStart = Date.now();
const listRes = http.get(`${BASE_URL}/api/produk?page=1&per_page=20&kategori=elektronik`, {
headers,
tags: { name: 'GET /produk' },
});
produkListDuration.add(Date.now() - listStart);
check(listRes, {
'list status 200': (r) => r.status === 200,
'list ada data': (r) => r.json('data.items') !== undefined,
'response time <300ms': (r) => r.timings.duration < 300,
});
sleep(1);
// 3. Tambah ke keranjang (simulasi write operation)
const addRes = http.post(`${BASE_URL}/api/keranjang`, JSON.stringify({
produk_id: 'prod_test_001',
jumlah: 1,
}), {
headers,
tags: { name: 'POST /keranjang' },
});
check(addRes, {
'add to cart 201': (r) => r.status === 201 || r.status === 200,
});
sleep(Math.random() * 2 + 1); // jeda acak 1-3 detik (simulasi think time)
}
Jalankan tes dengan perintah berikut dan simpan hasil ke file JSON untuk analisis lanjutan:
# Jalankan load test terhadap staging environment
BASE_URL=https://staging-api.tokoku.id k6 run scripts/load-test-api.js
# Jalankan dengan output JSON untuk analisis atau Grafana
k6 run --out json=hasil/load-test-$(date +%Y%m%d-%H%M).json scripts/load-test-api.js
# Jalankan stress test: override stages dari command line
k6 run --stage 30s:500 --stage 1m:500 --stage 30s:0 scripts/load-test-api.js
# Integrasi CI/CD — test gagal otomatis jika threshold dilanggar
k6 run --exit-on-running scripts/load-test-api.js
echo "Exit code: $?" # 0 = pass, 99 = threshold dilanggar
Membaca dan Menginterpretasikan Hasil k6
Output k6 berisi puluhan metrik. Berikut yang paling kritis untuk diperhatikan:
| Metrik k6 | Arti | Target Sehat | Tanda Bahaya |
|---|---|---|---|
http_req_duration p(95) |
95% request selesai dalam waktu ini | < 500ms | > 1000ms |
http_req_failed |
Persentase request yang error (4xx/5xx atau timeout) | < 0.1% | > 1% |
http_reqs |
Total request per detik (RPS/throughput) | Sesuai target | Stagnan saat VU naik |
iteration_duration |
Durasi satu siklus skenario lengkap | < 5s | > 10s |
vus_max |
Jumlah VU maksimum yang dicapai | Sesuai target | Tidak mencapai target |
data_received |
Total data yang diterima | Proporsional | Terlalu besar (payload bloat) |
Rumus. Throughput = VU aktif / iteration_duration (detik). Jika Anda punya 100 VU dan setiap iterasi rata-rata 5 detik, throughput maksimum Anda adalah 20 RPS. Untuk meningkatkan throughput, turunkan latency (kode/query lebih cepat) atau tambah instance (horizontal scaling).
Artillery — Alternatif Berbasis YAML
Artillery cocok untuk tim yang lebih nyaman mendefinisikan skenario dalam format deklaratif YAML tanpa menulis kode JavaScript. Artillery mendukung HTTP, Socket.io, dan WebSocket secara native, dengan plugin untuk Playwright (browser testing) dan Lambda (serverless execution).
# artillery/skenario-checkout.yml
config:
target: "https://staging-api.tokoku.id"
phases:
- duration: 60
arrivalRate: 10 # 10 user baru per detik
name: "Warm up"
- duration: 120
arrivalRate: 50 # 50 user baru per detik — beban puncak
name: "Puncak"
- duration: 30
arrivalRate: 5
name: "Cool down"
defaults:
headers:
Content-Type: "application/json"
plugins:
expect: {} # validasi response
ensure:
p95: 500 # performance budget: p95 < 500ms
maxErrorRate: 1 # error rate maks 1%
scenarios:
- name: "Alur Checkout"
weight: 7 # 70% traffic simulasi alur ini
flow:
- post:
url: "/api/auth/login"
json:
email: "{{ $randomString() }}@test.com"
password: "Test1234!"
capture:
- json: "$.data.token"
as: "token"
expect:
- statusCode: 200
- get:
url: "/api/produk?page=1"
headers:
Authorization: "Bearer {{ token }}"
expect:
- statusCode: 200
- contentType: json
- post:
url: "/api/checkout"
headers:
Authorization: "Bearer {{ token }}"
json:
items:
- produk_id: "prod_001"
jumlah: 2
expect:
- statusCode: 201
- name: "Browse Saja"
weight: 3 # 30% hanya browsing tanpa checkout
flow:
- get:
url: "/api/produk"
- get:
url: "/api/kategori"
Locust — Load Testing dengan Python
Locust adalah pilihan populer untuk tim Python. Skenario ditulis sebagai kelas Python biasa, membuatnya mudah diintegrasikan dengan logika bisnis yang kompleks. Locust menyediakan web UI bawaan untuk monitoring real-time dan bisa didistribusikan ke banyak worker untuk simulasi jutaan pengguna.
# locustfile.py — simulasi pengguna toko online
from locust import HttpUser, task, between
import json
import random
class PenggunaToko(HttpUser):
wait_time = between(1, 3) # jeda acak 1-3 detik antar task
token = None
def on_start(self):
"""Dipanggil satu kali saat VU mulai — gunakan untuk login"""
resp = self.client.post("/api/auth/login", json={
"email": f"user{random.randint(1,100)}@test.com",
"password": "Test1234!"
})
if resp.status_code == 200:
self.token = resp.json()["data"]["token"]
self.client.headers.update({"Authorization": f"Bearer {self.token}"})
@task(5) # bobot 5x: dilakukan 5x lebih sering
def lihat_daftar_produk(self):
page = random.randint(1, 5)
with self.client.get(f"/api/produk?page={page}&per_page=20",
name="/api/produk", catch_response=True) as resp:
if resp.status_code != 200:
resp.failure(f"Expected 200, got {resp.status_code}")
elif resp.elapsed.total_seconds() > 0.5:
resp.failure(f"Response terlalu lambat: {resp.elapsed.total_seconds():.2f}s")
@task(2)
def cari_produk(self):
kata = random.choice(["laptop", "handphone", "printer", "kamera"])
self.client.get(f"/api/produk?q={kata}", name="/api/produk?q=[keyword]")
@task(1) # bobot 1x: jarang dilakukan
def tambah_ke_keranjang(self):
self.client.post("/api/keranjang", json={
"produk_id": f"prod_{random.randint(1, 50):03d}",
"jumlah": random.randint(1, 3)
})
Identifikasi Bottleneck dari Hasil Load Test
Hasil load test menunjukkan gejala, bukan diagnosis. Proses identifikasi bottleneck membutuhkan korelasi antara metrik load test, APM (Application Performance Monitoring), dan database slow query log. Pola umum yang perlu dikenali: jika http_req_duration melonjak tajam saat VU naik tetapi RPS stagnan, biasanya bottleneck ada di database atau connection pool. Jika error rate naik tetapi latency tetap normal, biasanya bottleneck ada di resource limit (CPU, memory, file descriptor). Jika latency naik linear bersama VU, kemungkinan ada kode yang memproses request secara sinkron padahal bisa paralel.
Tips. Selalu jalankan load test sambil memantau tiga panel sekaligus: (1) dashboard k6/Artillery, (2)
htopatau CloudWatch di server, (3)pg_stat_activityatau slow query log di database. Bottleneck hampir selalu terlihat jelas di salah satu dari ketiganya.
Security Testing
Keamanan aplikasi bukan fitur tambahan yang dikerjakan di akhir proyek — ia adalah fondasi yang harus dibangun sejak baris kode pertama. Kenyataan di lapangan menunjukkan bahwa sebagian besar insiden keamanan bukan akibat serangan canggih yang mahal, melainkan kerentanan sederhana yang terabaikan: kolom form yang tidak divalidasi, token yang disimpan di localStorage, atau endpoint yang lupa diberi autentikasi. OWASP (Open Worldwide Application Security Project) mendokumentasikan sepuluh kategori kerentanan paling kritis yang ditemukan berulang kali di ribuan aplikasi. Memahami dan menguji kesepuluh kategori ini adalah standar minimum security testing untuk setiap aplikasi produksi.
OWASP Top 10 — Peta Jalan Security Testing
A01: Broken Access Control
Kerentanan paling umum dan paling merusak. Terjadi ketika pengguna dapat mengakses resource atau melakukan aksi yang seharusnya di luar izinnya. Contoh nyata: pengguna biasa dapat mengakses endpoint admin, pengguna A dapat melihat data pengguna B hanya dengan mengubah ID di URL, atau operasi DELETE tidak memverifikasi kepemilikan resource.
Pengujian: coba akses /api/admin/users tanpa token admin, coba ubah user_id di payload request ke ID pengguna lain, coba kirim DELETE /api/produk/123 menggunakan token pengguna yang bukan pemilik produk tersebut.
A02: Cryptographic Failures
Data sensitif yang tidak terenkripsi atau menggunakan algoritma kriptografi usang. Termasuk: password disimpan sebagai plain text atau MD5, data kartu kredit dikirim melalui HTTP (bukan HTTPS), JWT menggunakan algoritma none, atau kunci enkripsi di-hardcode dalam kode sumber.
A03: Injection
SQL Injection, Command Injection, LDAP Injection, dan variannya. Penyerang memasukkan kode berbahaya sebagai data yang kemudian dieksekusi oleh interpreter. Ini adalah kerentanan yang sudah berumur 20+ tahun namun masih ditemukan di ratusan aplikasi setiap tahun, termasuk sistem keuangan dan pemerintahan di Indonesia.
A04: Insecure Design
Kerentanan akibat kelemahan desain arsitektur, bukan hanya implementasi. Contoh: alur reset password yang menggunakan pertanyaan keamanan mudah ditebak, fitur bulk export yang tidak membatasi jumlah record, atau tidak ada mekanisme rate limiting pada proses autentikasi.
A05: Security Misconfiguration
Konfigurasi default yang tidak aman: database admin panel terbuka ke internet, error message yang menampilkan stack trace lengkap ke pengguna, directory listing aktif di web server, atau CORS yang dikonfigurasi Access-Control-Allow-Origin: * untuk semua endpoint termasuk yang sensitif.
A06: Vulnerable and Outdated Components
Menggunakan library, framework, atau sistem operasi dengan kerentanan yang sudah diketahui publik. Salah satu insiden terbesar dalam sejarah (Equifax breach 2017) terjadi akibat tidak memperbarui Apache Struts yang sudah memiliki patch. Gunakan npm audit, composer audit, atau Snyk secara rutin.
A07: Identification and Authentication Failures
Implementasi autentikasi yang lemah: tidak ada pembatasan percobaan login (brute force), password yang lemah diperbolehkan, session tidak diinvalidasi setelah logout, atau token JWT yang tidak punya expiry time.
A08: Software and Data Integrity Failures
Menggunakan plugin, library, atau update dari sumber yang tidak terverifikasi tanpa memeriksa integritas (checksum/signature). Termasuk pipeline CI/CD yang dapat dimodifikasi oleh pihak ketiga tanpa verifikasi.
A09: Security Logging and Monitoring Failures
Tidak ada log untuk event keamanan kritis: percobaan login gagal, akses ditolak, perubahan data sensitif. Tanpa log yang adequate, penyerangan dapat berlangsung berbulan-bulan tanpa terdeteksi. BSSN (Badan Siber dan Sandi Negara) merekomendasikan retention log minimal 3 bulan untuk aplikasi layanan publik.
A10: Server-Side Request Forgery (SSRF)
Terjadi ketika aplikasi mengambil resource dari URL yang ditentukan pengguna tanpa validasi. Penyerang dapat mengarahkan server untuk mengakses layanan internal: http://169.254.169.254/latest/meta-data/ (AWS metadata endpoint), database internal, atau Redis yang tidak terproteksi.
SQL Injection — Kerentanan dan Pencegahan
SQL Injection adalah ketika input pengguna disisipkan langsung ke dalam query SQL tanpa sanitasi, memungkinkan penyerang mengubah logika query tersebut.
// RENTAN: string concatenation langsung ke query SQL
// Jangan pernah lakukan ini!
app.get('/api/produk', async (req, res) => {
const { nama } = req.query;
// Jika nama = "' OR '1'='1", query menjadi:
// SELECT * FROM produk WHERE nama = '' OR '1'='1' AND deleted_at IS NULL
// — mengembalikan SEMUA produk, bypass filter apapun!
const query = `SELECT * FROM produk WHERE nama = '${nama}' AND deleted_at IS NULL`;
const produk = await db.raw(query);
res.json(produk);
});
// Lebih parah: jika nama = "'; DROP TABLE produk; --"
// Query pertama selesai, lalu DROP TABLE dieksekusi!
// AMAN: Parameterized query — selalu gunakan ini
import { Kysely, PostgresDialect, sql } from 'kysely';
app.get('/api/produk', async (req, res) => {
const { nama, kategori_id, page = 1, per_page = 20 } = req.query;
// Validasi input terlebih dahulu
const pageNum = Math.max(1, parseInt(String(page), 10) || 1);
const perPageNum = Math.min(100, Math.max(1, parseInt(String(per_page), 10) || 20));
const offset = (pageNum - 1) * perPageNum;
// Kysely menggunakan parameterized query secara otomatis
// Input user TIDAK PERNAH langsung digabung ke string SQL
let query = db.selectFrom('produk')
.selectAll()
.where('deleted_at', 'is', null)
.limit(perPageNum)
.offset(offset);
if (nama) {
// Parameterized — aman dari injection
query = query.where('nama', 'ilike', `%${nama}%`);
}
if (kategori_id) {
query = query.where('kategori_id', '=', String(kategori_id));
}
const produk = await query.execute();
res.json({ data: { items: produk, page: pageNum, per_page: perPageNum } });
});
// Untuk raw SQL yang tidak bisa dihindari, gunakan binding eksplisit:
const hasil = await db.executeQuery(
sql`SELECT * FROM produk WHERE id = ${produkId} AND deleted_at IS NULL`.compile(db)
);
XSS — Cross-Site Scripting Prevention
XSS terjadi ketika aplikasi menampilkan input pengguna di halaman HTML tanpa escaping, sehingga skrip berbahaya bisa dieksekusi di browser korban. Ada tiga jenis: Reflected XSS (payload dalam URL), Stored XSS (payload disimpan di database), dan DOM-based XSS (manipulasi DOM langsung di client).
// RENTAN: menyisipkan HTML tanpa escaping
// Backend Express — mengembalikan nama sebagai HTML mentah
app.get('/api/komentar/:id', async (req, res) => {
const komentar = await db.selectFrom('komentar')
.where('id', '=', req.params.id)
.executeTakeFirst();
// Jika komentar.isi = "<script>document.cookie=...</script>"
// Browser akan mengeksekusi script tersebut!
res.send(`<div class="komentar">${komentar.isi}</div>`);
});
// Frontend React — berbahaya dengan dangerouslySetInnerHTML
function Komentar({ isi }) {
// JANGAN lakukan ini kecuali Anda sudah sanitasi server-side
return <div dangerouslySetInnerHTML={{ __html: isi }} />;
}
// AMAN: escaping + sanitasi + Content Security Policy
// 1. Backend — selalu escape output HTML
import { escape as htmlEscape } from 'html-escaper';
import DOMPurify from 'isomorphic-dompurify';
// Jika harus mengembalikan HTML (rich text editor), sanitasi dulu
function sanitasiHtml(html) {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'li', 'ol'],
ALLOWED_ATTR: [], // Tidak ada atribut — menghilangkan event handler
});
}
// 2. Frontend React — gunakan text content, bukan innerHTML
function Komentar({ isi }) {
// React secara default mengescaping string — ini aman
return <div className="komentar">{isi}</div>;
// Jika HARUS render HTML, sanitasi terlebih dahulu:
// const bersih = DOMPurify.sanitize(isi);
// return <div dangerouslySetInnerHTML={{ __html: bersih }} />;
}
// 3. Content Security Policy header di Express
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'nonce-{RANDOM_NONCE}'; " + // Hanya skrip dengan nonce valid
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"connect-src 'self' https://api.tokoku.id; " +
"frame-ancestors 'none';" // Mencegah clickjacking
);
next();
});
CSRF Protection
Cross-Site Request Forgery memaksa pengguna yang sudah login melakukan aksi yang tidak diinginkan. Contoh: pengguna mengunjungi situs berbahaya yang secara diam-diam mengirim request ke bank online korban menggunakan cookie sesi yang sudah ada di browser.
// Express — implementasi CSRF protection dengan token
import csrf from 'csurf';
import cookieParser from 'cookie-parser';
app.use(cookieParser());
// Untuk API JSON: gunakan metode double-submit cookie atau custom header
// Browser tidak bisa mengirim custom header dari domain lain (SOP)
app.use((req, res, next) => {
// Metode custom header: X-Requested-With hanya bisa dikirim dari JavaScript same-origin
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
const xRequestedWith = req.headers['x-requested-with'];
if (xRequestedWith !== 'XMLHttpRequest') {
return res.status(403).json({
error: 'CSRF validation failed',
message: 'Request harus menggunakan header X-Requested-With: XMLHttpRequest',
});
}
}
next();
});
// Konfigurasi CORS yang ketat — JANGAN gunakan wildcard untuk endpoint sensitif
app.use(cors({
origin: (origin, callback) => {
const allowed = ['https://tokoku.id', 'https://www.tokoku.id', 'https://admin.tokoku.id'];
if (!origin || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} tidak diizinkan oleh CORS`));
}
},
credentials: true, // Izinkan cookie cross-origin hanya ke domain yang allowed
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
}));
// SameSite cookie — lapisan perlindungan tambahan
res.cookie('session_id', token, {
httpOnly: true, // Tidak bisa diakses JavaScript (XSS protection)
secure: true, // Hanya HTTPS
sameSite: 'strict', // Cookie TIDAK dikirim pada request cross-site
maxAge: 24 * 60 * 60 * 1000, // 24 jam
});
Rate Limiting
Rate limiting melindungi aplikasi dari brute force, credential stuffing, dan serangan denial of service aplikasi (berbeda dari DDoS yang ditangani di layer network). Implementasikan rate limiting berlapis: global (semua endpoint), per-endpoint sensitif (login, reset password), dan per-pengguna (setelah login).
// Express rate limiting dengan Redis sebagai store (persist across instances)
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redisClient } from './redis';
// Rate limit global — 100 request per menit per IP
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 menit
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
handler: (req, res) => {
res.status(429).json({
error: 'Too Many Requests',
message: 'Terlalu banyak request. Coba lagi dalam 1 menit.',
retry_after: Math.ceil(res.getHeader('Retry-After') / 1000),
});
},
});
// Rate limit ketat untuk endpoint auth — 5 percobaan per 15 menit per IP
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 menit
max: 5,
skipSuccessfulRequests: true, // Hanya hitung request GAGAL
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
prefix: 'rl:auth:',
}),
handler: (req, res) => {
// Log untuk security monitoring
console.error(`[SECURITY] Brute force detected: IP=${req.ip} email=${req.body?.email}`);
res.status(429).json({
error: 'Account Locked',
message: 'Terlalu banyak percobaan login gagal. Akun dikunci sementara 15 menit.',
});
},
});
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/reset-password', authLimiter);
Security Headers
Security headers adalah lapisan pertahanan pertama yang dikonfigurasi di web server atau middleware. Helmet.js menyederhanakan konfigurasi semua header keamanan standar untuk aplikasi Express:
// Security headers dengan Helmet.js
import helmet from 'helmet';
app.use(helmet({
// Strict-Transport-Security: paksa HTTPS selama 1 tahun
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
// X-Content-Type-Options: cegah MIME sniffing
noSniff: true,
// X-Frame-Options: cegah clickjacking
frameguard: { action: 'deny' },
// X-XSS-Protection (untuk browser lama)
xssFilter: true,
// Referrer-Policy: batasi informasi referrer
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
// Permissions-Policy: batasi fitur browser
permissionsPolicy: {
features: {
camera: [], // Larang akses kamera
microphone: [], // Larang akses mikrofon
geolocation: ['self'], // Izinkan hanya origin sendiri
},
},
}));
Awas. Jangan pernah menyimpan informasi sensitif (token JWT, ID sesi) di
localStoragekarena bisa diakses oleh XSS. GunakanhttpOnlycookie untuk sesi, atau simpan token di memori JavaScript (variabel) yang hilang saat halaman di-refresh — pilih berdasarkan trade-off UX dan tingkat risiko aplikasi Anda.
Code Quality
Kode yang bekerja dan kode yang berkualitas adalah dua hal berbeda. Kode yang bekerja menyelesaikan masalah hari ini; kode berkualitas masih bisa dipahami, dimodifikasi, dan dikembangkan enam bulan kemudian oleh orang yang berbeda — termasuk diri Anda sendiri. Di startup Indonesia yang bergerak cepat, godaan untuk skip code review dan menulis kode “asal jalan” sangat besar. Namun tekanan ini menciptakan akumulasi tech debt yang pada akhirnya membuat tim melambat justru di saat momentum bisnis paling dibutuhkan. Investasi dalam tooling dan proses code quality adalah salah satu leverage tertinggi yang bisa dilakukan tim engineering kecil.
ESLint — Static Analysis untuk JavaScript dan TypeScript
ESLint menganalisis kode Anda tanpa menjalankannya, mendeteksi pola yang berpotensi bermasalah: variabel yang dideklarasikan tapi tidak digunakan, perbandingan dengan == alih-alih ===, atau penggunaan console.log yang lupa dihapus sebelum push ke production. Konfigurasi ESLint yang baik adalah aset jangka panjang yang mencegah seluruh kelas bug secara otomatis.
// eslint.config.mjs — konfigurasi ESLint v9 (flat config)
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import unicorn from 'eslint-plugin-unicorn';
export default [
js.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
ecmaVersion: 'latest',
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': typescript,
import: importPlugin,
unicorn,
},
rules: {
// TypeScript rules
'@typescript-eslint/no-explicit-any': 'error', // Larang 'any'
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_', // Izinkan prefix _
varsIgnorePattern: '^_',
}],
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-floating-promises': 'error', // Wajib await promise
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
// Import rules
'import/order': ['error', {
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
alphabetize: { order: 'asc' },
}],
'import/no-cycle': 'error', // Deteksi circular dependency
// General
'no-console': ['warn', { allow: ['warn', 'error'] }], // Larang console.log di production
'eqeqeq': ['error', 'always'], // Wajib ===
'no-var': 'error', // Larang var
'prefer-const': 'error',
'unicorn/no-array-for-each': 'error', // Pakai for...of atau map
'unicorn/prefer-string-slice': 'error', // slice > substr/substring
},
},
{
// Rules berbeda untuk file test
files: ['**/*.test.ts', '**/*.spec.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off', // Test boleh pakai any
'no-console': 'off',
},
},
{
// File yang dikecualikan
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.generated.ts'],
},
];
Prettier — Formatting Otomatis
Prettier menghilangkan seluruh diskusi “formatting wars” dari code review. Tidak ada lagi debat tentang tab vs spasi, single vs double quote, atau trailing comma. Prettier mengambil alih keputusan formatting secara otomatis dan konsisten. Integrasikan Prettier dengan ESLint menggunakan eslint-config-prettier untuk mematikan aturan ESLint yang berkonflik dengan Prettier.
// .prettierrc.json
{
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"overrides": [
{
"files": "*.json",
"options": { "printWidth": 200 }
},
{
"files": "*.md",
"options": {
"proseWrap": "always",
"printWidth": 80
}
}
]
}
// package.json — scripts untuk linting dan formatting
{
"scripts": {
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "tsc --noEmit",
"quality": "npm run type-check && npm run lint && npm run format:check",
"quality:fix": "npm run format && npm run lint:fix"
},
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix --max-warnings 0", "prettier --write"],
"*.{js,jsx,json,css,md}": ["prettier --write"]
}
}
TypeScript Strict Mode
TypeScript dengan konfigurasi default terlalu permisif. strict: true mengaktifkan serangkaian pemeriksaan yang mengubah TypeScript dari “JavaScript dengan type hints opsional” menjadi type system yang sesungguhnya ketat. Aktifkan sejak awal proyek; menambahkannya ke proyek yang sudah berjalan membutuhkan effort besar untuk memperbaiki type errors.
// tsconfig.json — konfigurasi TypeScript strict untuk production
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
// === STRICT MODE — aktifkan semua ===
"strict": true, // Aktifkan semua flag di bawah sekaligus
"noImplicitAny": true, // Larang type 'any' implisit
"strictNullChecks": true, // null & undefined tidak bisa dipakai sembarangan
"strictFunctionTypes": true, // Periksa contravariance parameter fungsi
"strictBindCallApply": true, // Periksa bind/call/apply
"strictPropertyInitialization": true, // Property class wajib diinisialisasi
"noImplicitThis": true, // Larang 'this' dengan type implisit
"alwaysStrict": true, // Emit "use strict" di semua file
// === TAMBAHAN YANG SANGAT DIREKOMENDASIKAN ===
"noUncheckedIndexedAccess": true, // array[i] mengembalikan T | undefined
"noImplicitReturns": true, // Semua code path fungsi harus return
"noFallthroughCasesInSwitch": true, // Larang case tanpa break/return
"noUnusedLocals": true, // Variabel lokal tidak terpakai = error
"noUnusedParameters": true, // Parameter tidak terpakai = error
"exactOptionalPropertyTypes": true, // {a?: string} berbeda dari {a: string | undefined}
"useUnknownInCatchVariables": true, // catch (e) → e bertipe 'unknown', bukan 'any'
// === PATH ALIAS ===
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@modules/*": ["src/modules/*"],
"@shared/*": ["src/shared/*"]
},
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
SonarQube — Analisis Kualitas Kode Berkelanjutan
SonarQube adalah platform analisis kualitas kode yang melampaui ESLint. SonarQube menganalisis code coverage, duplikasi kode, kompleksitas siklomatis, dan security hotspot secara terintegrasi. Tersedia sebagai SonarCloud (SaaS gratis untuk proyek open-source) atau self-hosted. Integrasikan ke CI/CD agar setiap pull request mendapat laporan otomatis.
Inti. Empat metrik utama SonarQube yang perlu dijaga: (1) Coverage >80% untuk kode kritis, (2) Duplications <3% (DRY), (3) Cognitive Complexity <15 per fungsi (KISS), (4) Technical Debt rasio <5% (waktu perbaikan dibagi total waktu pengembangan).
Code Review Checklist
Code review yang efektif membutuhkan checklist yang konkret. Review tanpa checklist cenderung berfokus pada style (yang seharusnya sudah ditangani oleh linter) dan melewatkan masalah logika atau keamanan yang lebih kritis.
| Kategori | Item yang Diperiksa | Prioritas |
|---|---|---|
| Keamanan | Input divalidasi sebelum diproses; query pakai parameterized; tidak ada secret di kode | Kritis |
| Kebenaran | Edge case (null, empty array, angka negatif) ditangani; kondisi batas benar | Kritis |
| Soft Delete | Setiap query baca sudah filter deleted_at IS NULL; ada endpoint restore |
Kritis |
| Audit Trail | created_by/updated_by/deleted_by diisi dari user sesi aktif |
Tinggi |
| Performa | Tidak ada N+1 query; index ada untuk kolom yang di-WHERE/ORDER BY | Tinggi |
| Test | Ada unit test untuk logika baru; coverage tidak turun | Tinggi |
| API Contract | Response shape konsisten dengan dokumentasi; error response berformat standar | Sedang |
| Nama | Nama variabel/fungsi/file bermakna dalam Bahasa Inggris konsisten | Sedang |
| DRY | Tidak ada duplikasi logika yang bisa dijadikan fungsi/hook bersama | Sedang |
| Dokumentasi | JSDoc untuk fungsi publik yang kompleks; komentar untuk logika bisnis non-obvious | Rendah |
Tech Debt Management
Tech debt bukan selalu buruk — kadang mengambil shortcut yang disengaja untuk mengejar deadline adalah keputusan bisnis yang valid. Yang berbahaya adalah tech debt yang tidak teridentifikasi, tidak terdokumentasi, dan tidak pernah dibayar. Strategi yang efektif: tandai semua tech debt dengan komentar // TODO(debt): <deskripsi> @<tanggal>, lacak di GitHub Issues dengan label tech-debt, dan alokasikan 20% sprint capacity untuk pembayaran debt secara reguler. Gunakan eslint-plugin-todo-expiry untuk memastikan TODO tidak hidup lebih dari 90 hari tanpa di-review.
Database Migration (Skala)
Satu instance PostgreSQL bisa menangani ratusan ribu transaksi per detik dan menyimpan terabyte data dengan konfigurasi yang tepat. Namun ada batas dimana satu server tidak lagi cukup — bukan karena kapasitas penyimpanannya habis, melainkan karena beban baca dan tulis melebihi kapabilitas satu node. Di titik ini, arsitektur database harus berkembang dari setup tunggal menjadi topologi multi-node. Bab ini membahas empat strategi fundamental: read replicas untuk mendistribusikan beban baca, PgBouncer untuk mengelola koneksi secara efisien, query routing untuk mengarahkan traffic ke node yang tepat, dan sharding untuk distribusi data horizontal ketika volume sudah melampaui skala vertikal.
Memahami Batas Skala Single Node
Sebelum menambahkan kompleksitas arsitektur multi-node, pastikan Anda sudah memaksimalkan single node. Sebagian besar aplikasi Indonesia di tahap startup dan growth bisa dilayani oleh satu PostgreSQL instance yang dikonfigurasi dengan baik: SSD NVMe, RAM 32–64 GB, shared_buffers 25% RAM, effective_cache_size 75% RAM, dan work_mem 64MB. Dengan indeks yang tepat dan query yang dioptimasi, satu node PostgreSQL mampu melayani 1000+ concurrent connections dan ribuan transaksi per detik.
Tanda-tanda Anda perlu scale out: CPU database konsisten >80%, query yang sudah dioptimasi tetap lambat karena disk I/O saturated, atau Anda memiliki banyak reporting query berat yang mengganggu operasi transaksi utama.
Read Replicas — Distribusi Beban Baca
PostgreSQL mendukung streaming replication bawaan: primary menerima semua write, replica mengalirkan WAL (Write-Ahead Log) dari primary dan menerapkannya secara real-time. Replica bersifat read-only — query SELECT bisa diarahkan ke replica, sehingga primary terbebas dari beban baca yang bisa mencapai 70-80% dari total query di aplikasi CRUD tipikal.
PgBouncer — Connection Pooling
PostgreSQL membuat proses OS baru untuk setiap koneksi database. Proses ini membutuhkan sekitar 5–10 MB RAM dan puluhan milidetik untuk diinisialisasi. Aplikasi modern dengan ratusan worker thread atau goroutine akan membuat ratusan koneksi simultan, menghabiskan RAM dan melampaui batas max_connections PostgreSQL (default 100). PgBouncer adalah connection pooler yang duduk di antara aplikasi dan PostgreSQL: aplikasi membuka ratusan koneksi ke PgBouncer, PgBouncer memaintain pool kecil koneksi nyata ke PostgreSQL.
# /etc/pgbouncer/pgbouncer.ini — konfigurasi PgBouncer production
[databases]
# Format: alias = host=HOST dbname=DBNAME
tokoku_primary = host=primary.db.tokoku.id port=5432 dbname=tokoku
tokoku_replica = host=replica1.db.tokoku.id port=5432 dbname=tokoku
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# Mode pooling
# transaction: koneksi dikembalikan ke pool setelah tiap transaksi (DIREKOMENDASIKAN)
# session: koneksi dikembalikan setelah sesi disconnect
# statement: koneksi dikembalikan setelah tiap statement (tidak cocok untuk transaksi)
pool_mode = transaction
# Ukuran pool per database per user
default_pool_size = 25 # 25 koneksi nyata ke PostgreSQL per pool
min_pool_size = 5 # Jaga minimal 5 koneksi siap
reserve_pool_size = 5 # Koneksi cadangan untuk lonjakan
max_client_conn = 1000 # Maksimum koneksi dari aplikasi ke PgBouncer
# Timeout
server_connect_timeout = 15 # Timeout membuka koneksi ke PostgreSQL
server_login_retry = 15 # Retry interval jika PostgreSQL tidak tersedia
query_timeout = 30 # Query yang melebihi 30 detik akan dibatalkan
client_idle_timeout = 600 # Koneksi idle dari client ditutup setelah 10 menit
# Monitoring
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
logfile = /var/log/pgbouncer/pgbouncer.log
pidfile = /var/run/pgbouncer/pgbouncer.pid
# Memantau status PgBouncer
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer
# Lihat statistik pool
SHOW POOLS;
-- Kolom penting: cl_active (client aktif), sv_active (server aktif), sv_idle (server idle)
# Lihat semua database yang dikonfigurasi
SHOW DATABASES;
# Statistik query per database
SHOW STATS;
-- total_query_time: total waktu query; avg_query_time = total/total_query_count
# Reload konfigurasi tanpa restart (untuk perubahan kecil)
RELOAD;
# Pause koneksi ke database tertentu (untuk maintenance)
PAUSE tokoku_primary;
RESUME tokoku_primary;
Query Routing — Master-Write, Replica-Read
Dengan PgBouncer di depan masing-masing node (primary dan replica), lapisan aplikasi perlu mengarahkan query ke koneksi yang tepat. Pendekatan yang paling clean adalah membuat dua instance client database yang berbeda dan mengekspos abstraksi sederhana ke seluruh aplikasi.
// src/shared/database/client.ts — abstraksi query routing
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import type { DB } from './types';
// Pool koneksi ke primary (write) — via PgBouncer
const primaryPool = new Pool({
host: process.env.DB_PRIMARY_HOST || '127.0.0.1',
port: parseInt(process.env.DB_PRIMARY_PORT || '6432'),
database: process.env.DB_NAME || 'tokoku',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // PgBouncer sudah pool — ini koneksi ke PgBouncer, bukan PostgreSQL
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// Pool koneksi ke replica (read) — via PgBouncer replica
const replicaPool = new Pool({
host: process.env.DB_REPLICA_HOST || '127.0.0.1',
port: parseInt(process.env.DB_REPLICA_PORT || '6433'),
database: process.env.DB_NAME || 'tokoku',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 30, // Lebih banyak karena mostly read
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// Instans Kysely terpisah
export const dbWrite = new Kysely<DB>({
dialect: new PostgresDialect({ pool: primaryPool }),
});
export const dbRead = new Kysely<DB>({
dialect: new PostgresDialect({ pool: replicaPool }),
});
// Helper: fallback ke primary jika replica tidak tersedia
// (untuk operasi yang membutuhkan data terkini — hindari replication lag)
export function dbReadStrong(): Kysely<DB> {
if (process.env.REPLICA_AVAILABLE !== 'true') {
return dbWrite; // Gunakan primary sebagai fallback
}
return dbRead;
}
// Contoh penggunaan di service layer
export class ProdukService {
// Read → gunakan replica
async findAll(filter: ProdukFilter): Promise<Produk[]> {
return dbRead
.selectFrom('produk')
.selectAll()
.where('deleted_at', 'is', null)
.where('kategori_id', '=', filter.kategoriId)
.orderBy('created_at', 'desc')
.limit(filter.perPage)
.offset((filter.page - 1) * filter.perPage)
.execute();
}
// Write → wajib primary
async create(data: CreateProdukDto, userId: string): Promise<Produk> {
return dbWrite
.insertInto('produk')
.values({
id: generateUlid(),
...data,
created_at: new Date(),
updated_at: new Date(),
created_by: userId,
updated_by: userId,
})
.returningAll()
.executeTakeFirstOrThrow();
}
// Read setelah write → gunakan primary (hindari read-your-writes problem)
async createAndReturn(data: CreateProdukDto, userId: string): Promise<Produk> {
const produk = await this.create(data, userId);
// Langsung return hasil dari INSERT, tidak perlu SELECT lagi
return produk;
}
}
Awas. Replication lag adalah jeda waktu antara write di primary dan data yang tersedia di replica — biasanya <100ms untuk jaringan lokal, tapi bisa detik jika replica sibuk. Jika pengguna baru saja membuat data dan langsung membaca kembali, gunakan primary untuk read tersebut (read-your-writes consistency). Simpan flag di session: “pengguna ini baru saja melakukan write dalam 5 detik terakhir → route read ke primary.”
Sharding Strategy
Sharding membagi data secara horizontal ke beberapa node database. Berbeda dengan read replicas yang menduplikasi data, sharding membagi data: shard 1 menyimpan pengguna A–M, shard 2 menyimpan pengguna N–Z. Sharding diperlukan ketika volume data sudah melebihi kapasitas satu node (biasanya >1 TB setelah optimasi), atau ketika write throughput sudah melampaui batas satu instance meskipun sudah di-tune.
| Strategi Sharding | Cara Kerja | Keunggulan | Kelemahan |
|---|---|---|---|
| Range Sharding | Dibagi berdasarkan range nilai: ID 1–1jt di shard 1, 1jt–2jt di shard 2 | Query range efisien; mudah dipahami | Hotspot jika data baru selalu di shard terakhir |
| Hash Sharding | shard = hash(id) % total_shard |
Distribusi merata; tidak ada hotspot | Query range tidak efisien; resharding sulit |
| Directory Sharding | Tabel lookup menyimpan mapping entity → shard | Fleksibel; bisa migrasi shard tanpa ubah key | Tabel lookup jadi single point of failure |
| Geo Sharding | Data dibagi berdasarkan lokasi: Jawa di shard Jakarta, Sulawesi di shard Makassar | Latency rendah; compliance data residency | Cross-region query sangat lambat |
// Implementasi hash sharding sederhana di application layer
// (sebelum menggunakan Citus atau solusi sharding native)
const TOTAL_SHARDS = 4;
// Konfigurasi koneksi per shard
const shardPools: Record<number, Kysely<DB>> = {
0: createKyselyClient(process.env.DB_SHARD_0_URL!),
1: createKyselyClient(process.env.DB_SHARD_1_URL!),
2: createKyselyClient(process.env.DB_SHARD_2_URL!),
3: createKyselyClient(process.env.DB_SHARD_3_URL!),
};
// Fungsi menentukan shard dari tenant_id (untuk arsitektur multi-tenant)
function getShardIndex(tenantId: string): number {
// Ambil 4 karakter terakhir ULID sebagai basis hash
const suffix = tenantId.slice(-4);
let hash = 0;
for (let i = 0; i < suffix.length; i++) {
hash = (hash * 31 + suffix.charCodeAt(i)) & 0xffffffff;
}
return Math.abs(hash) % TOTAL_SHARDS;
}
// Router yang transparan untuk service layer
export function getShardClient(tenantId: string): Kysely<DB> {
const shardIndex = getShardIndex(tenantId);
const client = shardPools[shardIndex];
if (!client) {
throw new Error(`Shard ${shardIndex} tidak dikonfigurasi`);
}
return client;
}
// Penggunaan di service — service tidak perlu tahu tentang sharding
class OrderService {
async createOrder(tenantId: string, data: CreateOrderDto): Promise<Order> {
const db = getShardClient(tenantId);
return db.insertInto('orders')
.values({
id: generateUlid(),
tenant_id: tenantId,
...data,
created_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow();
}
// Cross-shard query — harus query ke semua shard dan merge hasilnya
// Ini adalah kelemahan utama sharding: hindari jika bisa
async findAllOrders(filter: OrderFilter): Promise<Order[]> {
const promises = Object.values(shardPools).map(db =>
db.selectFrom('orders')
.selectAll()
.where('status', '=', filter.status)
.where('created_at', '>=', filter.from)
.execute()
);
const results = await Promise.all(promises);
return results.flat().sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
}
Inti. Urutan scaling database yang benar: (1) Optimasi query + tambah index, (2) Upgrade hardware (scale up), (3) Tambah read replica + connection pooling, (4) Caching layer (Redis), (5) Read/write splitting, (6) Sharding. Jangan loncat ke sharding sebelum tahap 1–5 benar-benar sudah dieksekusi. Sharding menambahkan kompleksitas operasional yang signifikan dan membuat cross-entity query jauh lebih sulit. Untuk 99% aplikasi Indonesia di tahap MVP hingga Series A, read replicas + PgBouncer sudah lebih dari cukup.
Monitoring Kesehatan Database
Infrastruktur database yang sudah berkembang membutuhkan observabilitas yang proporsional. Konfigurasi alert untuk metrik-metrik berikut agar masalah terdeteksi sebelum pengguna merasakannya:
-- Query pemantauan replication lag (jalankan di primary)
SELECT
client_addr AS replica_host,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
-- Ukur lag dalam byte
(sent_lsn - replay_lsn) AS replication_lag_bytes,
-- Ukur lag dalam waktu
now() - pg_last_xact_replay_timestamp() AS replication_lag_time
FROM pg_stat_replication
ORDER BY replication_lag_bytes DESC;
-- Query untuk menemukan koneksi idle yang menumpuk
SELECT
pid,
usename,
application_name,
client_addr,
state,
now() - state_change AS idle_duration,
query
FROM pg_stat_activity
WHERE state = 'idle'
AND now() - state_change > INTERVAL '5 minutes'
ORDER BY idle_duration DESC;
-- Query untuk menemukan query yang berjalan lama (kandidat slow query)
SELECT
pid,
now() - pg_stat_activity.query_start AS duration,
query,
state
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > INTERVAL '30 seconds'
AND state = 'active'
ORDER BY duration DESC;
Tips. Gunakan pg_stat_statements extension untuk melacak query yang paling sering dieksekusi dan paling lambat secara agregat. Aktifkan dengan
CREATE EXTENSION pg_stat_statements;dan tambahkanshared_preload_libraries = 'pg_stat_statements'dipostgresql.conf. Ini adalah alat diagnosis performa database yang paling berharga — lebih actionable daripada slow query log karena sudah ter-aggregasi.
Bisnis & Monetisasi
Model Bisnis Aplikasi
Membuat aplikasi yang berfungsi hanyalah setengah perjuangan. Setengah lainnya adalah menjawab pertanyaan yang paling mendasar dalam dunia produk digital: dari mana uangnya datang? Model bisnis bukan sekadar keputusan administratif — ia menentukan arsitektur teknis yang Anda bangun, fitur yang diprioritaskan, jenis pelanggan yang Anda kejar, dan pada akhirnya, apakah perusahaan Anda bisa bertahan. Seorang developer yang tidak memahami model bisnis produknya sendiri adalah seperti arsitek yang mendesain gedung tanpa tahu siapa yang akan membayar sewanya.
Mengapa Pilihan Model Bisnis Mempengaruhi Keputusan Teknis
Model bisnis bukan hanya soal divisi keuangan. Ia menyentuh langsung ke kode yang Anda tulis. Model subscription SaaS membutuhkan sistem manajemen langganan, pengecekan status berlangganan di setiap request (middleware), logika grace period ketika pembayaran gagal, dan billing cycle yang akurat hingga hari. Model marketplace fee membutuhkan escrow, split payment, dan sistem reputasi penjual. Model freemium membutuhkan feature flagging yang sangat granular sehingga satu tombol di admin panel bisa membuka atau menutup fitur untuk tier tertentu tanpa deploy ulang. Memilih model bisnis yang salah — atau menggantinya di tengah jalan — bisa berarti refactor besar-besaran pada lapisan yang paling sulit diubah.
Tujuh Model Bisnis Utama untuk Aplikasi Digital
1. Subscription / SaaS (Software as a Service)
Pengguna membayar biaya berulang — bulanan atau tahunan — untuk mengakses layanan. Ini adalah model paling populer untuk aplikasi B2B dan B2C karena menghasilkan recurring revenue yang dapat diprediksi. Investor menyukai model ini karena visibilitas pendapatan tinggi: jika bulan ini Anda punya 500 pelanggan membayar Rp 299.000/bulan, Anda tahu setidaknya ada Rp 149.500.000 pendapatan bulan depan (minus churn). Metrik kunci: MRR (Monthly Recurring Revenue), ARR, Churn Rate, dan LTV (Lifetime Value).
Di Indonesia, model ini banyak dipakai oleh aplikasi HRIS (seperti Talenta, Mekari), akuntansi (Jurnal, Accurate Online), dan manajemen restoran (Moka, Majoo). Tantangan utama di pasar Indonesia: edukasi pelanggan bahwa mereka “menyewa” software, bukan membelinya. Resistensi ini berkurang seiring penetrasi internet dan meningkatnya kepercayaan pada cloud.
2. One-Time License (Enterprise)
Pelanggan membayar satu kali untuk hak menggunakan software selamanya, biasanya dengan biaya maintenance tahunan opsional sekitar 15–20% dari harga lisensi. Model ini umum di segmen pemerintah dan BUMN Indonesia yang proses pengadaannya ketat dan tidak cocok dengan billing bulanan. Kelemahan: tidak ada recurring revenue, sehingga tim sales harus terus menutup kontrak baru. Keunggulan: deal size besar, sering mencapai ratusan juta hingga miliaran rupiah per kontrak.
3. Freemium
Fitur dasar gratis selamanya, fitur premium berbayar. Tujuannya adalah membangun basis pengguna besar dengan cepat, lalu mengkonversi subset mereka menjadi pelanggan berbayar. Rasio konversi freemium ke premium yang sehat berkisar 2–5%. Dropbox, Notion, dan Canva adalah contoh klasik. Di Indonesia, GoSend dan OVO menggunakan variasi model ini. Risiko terbesar: Anda menanggung biaya infrastruktur untuk jutaan pengguna gratis yang tidak pernah upgrade.
4. Marketplace Fee / Commission
Platform mempertemukan pembeli dan penjual, lalu mengambil persentase dari setiap transaksi. Tokopedia, Shopee, dan Traveloka adalah contoh skala besar. Di skala startup, model ini bisa diterapkan pada marketplace niche: jasa freelancer lokal, marketplace tanaman hias, atau platform kursus online. Kunci teknis: sistem pembayaran yang andal, escrow, dan mekanisme dispute resolution.
5. Advertising
Pendapatan dari iklan yang ditampilkan kepada pengguna. Model ini hanya layak jika Anda memiliki traffic sangat tinggi dan data demografis yang kaya — dua hal yang sangat sulit dicapai oleh startup tahap awal. Gunakan hanya sebagai model sekunder atau komplementer, bukan sebagai model utama.
6. White Label
Anda membangun platform, lalu menjualnya kepada bisnis lain yang menggunakannya dengan brand mereka sendiri. Satu instansi pemerintah kabupaten membeli sistem e-Desa Anda, lalu meluncurkannya sebagai “SiDesa Maju” milik mereka. Model ini scalable secara margin karena marginal cost setiap klien baru sangat kecil setelah platform matang, namun membutuhkan arsitektur multi-tenant yang solid dari awal.
7. API Call-Based / Usage-Based Pricing
Pelanggan membayar sesuai konsumsi: per request, per transaksi, per GB data yang diproses. OpenAI, Twilio, dan Midtrans menggunakan model ini. Keunggulan: pelanggan kecil bisa masuk dengan biaya nol di awal (seperti freemium), namun berbeda dengan freemium, revenue tumbuh seiring pertumbuhan pelanggan. Tantangan teknis: sistem metering yang akurat, real-time usage dashboard, dan sistem alerting ketika pengguna mendekati batas.
Perbandingan Model Bisnis
| Model | Cocok Untuk | Keunggulan | Risiko Utama | Contoh Indonesia |
|---|---|---|---|---|
| Subscription / SaaS | B2B tools, produktivitas, HRIS, akuntansi | Recurring revenue, mudah diprediksi | Churn, edukasi pasar | Mekari, Jurnal, Talenta |
| One-Time License | Pemerintah, BUMN, enterprise besar | Deal size besar, sederhana | Tidak ada recurring, sales intensif | Sistem ERP vendor lokal |
| Freemium | Konsumen, developer tools, kolaborasi | Akuisisi cepat, network effect | Biaya infrastruktur tinggi | Canva (lokal: variasi OVO) |
| Marketplace Fee | Platform dua sisi, e-commerce niche | Tidak perlu modal inventori | Chicken-and-egg problem | Tokopedia, Traveloka |
| Advertising | Media, berita, hiburan massal | Tidak perlu bayar pengguna | Butuh traffic sangat besar | Detik.com, Kumparan |
| White Label | GovTech, perbankan, franchisor | Margin tinggi setelah matang | Multi-tenant complexity | Vendor sistem desa, core banking |
| API / Usage-Based | Developer tools, infrastruktur, AI | Revenue tumbuh dengan penggunaan | Sulit diprediksi, metering kompleks | Midtrans, Xendit, Fonnte |
Implementasi Teknis: Sistem Subscription SaaS
Contoh berikut menunjukkan struktur database dan middleware untuk model subscription. Setiap request API akan melewati middleware yang memverifikasi status langganan sebelum mengizinkan akses ke fitur premium.
-- Schema tabel subscription (PostgreSQL)
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL, -- no FK, referensial di app layer
plan_id VARCHAR(50) NOT NULL, -- 'starter', 'pro', 'enterprise'
status VARCHAR(20) NOT NULL DEFAULT 'active',
-- active | past_due | canceled | trialing
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
trial_end TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
payment_gateway_id VARCHAR(255), -- ID dari Midtrans/Xendit
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- soft delete wajib
);
CREATE INDEX idx_subscriptions_tenant ON subscriptions(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_subscriptions_status ON subscriptions(status, current_period_end) WHERE deleted_at IS NULL;
-- Tabel plan dan batasannya
CREATE TABLE subscription_plans (
id VARCHAR(50) PRIMARY KEY, -- 'starter', 'pro', 'enterprise'
name VARCHAR(100) NOT NULL,
price_monthly BIGINT NOT NULL, -- dalam Rupiah (sen tidak digunakan)
price_yearly BIGINT NOT NULL,
max_users INT NOT NULL DEFAULT 5,
max_projects INT NOT NULL DEFAULT 10,
features JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO subscription_plans VALUES
('starter', 'Starter', 299000, 2990000, 5, 10, '{"export_pdf": false, "api_access": false, "white_label": false}', TRUE, NOW()),
('pro', 'Pro', 799000, 7990000, 25, 100, '{"export_pdf": true, "api_access": true, "white_label": false}', TRUE, NOW()),
('enterprise', 'Enterprise', 2499000, 24990000, 999, 999, '{"export_pdf": true, "api_access": true, "white_label": true}', TRUE, NOW());
// middleware/subscription.ts — pengecekan status langganan per request
import { Request, Response, NextFunction } from 'express';
import { db } from '../lib/db';
export interface SubscriptionContext {
planId: string;
status: string;
features: Record<string, boolean>;
maxUsers: number;
maxProjects: number;
isActive: boolean;
}
/**
* Memeriksa apakah tenant memiliki subscription aktif.
* Jika past_due: izinkan akses dengan grace period 3 hari.
* Jika canceled / expired: blokir akses ke fitur premium.
*/
export async function requireActiveSubscription(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const tenantId = req.tenant?.id;
if (!tenantId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const sub = await db.queryOne<{
plan_id: string;
status: string;
current_period_end: Date;
features: Record<string, boolean>;
max_users: number;
max_projects: number;
}>(`
SELECT s.plan_id, s.status, s.current_period_end,
p.features, p.max_users, p.max_projects
FROM subscriptions s
JOIN subscription_plans p ON p.id = s.plan_id
WHERE s.tenant_id = $1
AND s.deleted_at IS NULL
ORDER BY s.created_at DESC
LIMIT 1
`, [tenantId]);
if (!sub) {
res.status(402).json({ error: 'Subscription tidak ditemukan', code: 'NO_SUBSCRIPTION' });
return;
}
const now = new Date();
const gracePeriodEnd = new Date(sub.current_period_end);
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + 3); // 3 hari grace period
const isActive =
sub.status === 'active' ||
sub.status === 'trialing' ||
(sub.status === 'past_due' && now <= gracePeriodEnd);
if (!isActive) {
res.status(402).json({
error: 'Langganan tidak aktif atau sudah berakhir',
code: 'SUBSCRIPTION_EXPIRED',
renewUrl: '/billing/renew',
});
return;
}
// Inject ke request agar handler bisa cek fitur
req.subscription = {
planId: sub.plan_id,
status: sub.status,
features: sub.features,
maxUsers: sub.max_users,
maxProjects: sub.max_projects,
isActive: true,
};
next();
}
/**
* Pengecekan fitur spesifik — gunakan setelah requireActiveSubscription
*/
export function requireFeature(featureKey: string) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.subscription?.features[featureKey]) {
res.status(403).json({
error: `Fitur '${featureKey}' tidak tersedia di plan Anda`,
code: 'FEATURE_NOT_AVAILABLE',
upgradeUrl: '/billing/upgrade',
});
return;
}
next();
};
}
Inti. Pilih model bisnis sebelum menulis satu baris kode pun. Model bisnis menentukan schema database, arsitektur middleware, dan bahkan tim yang Anda butuhkan. Mengubah model bisnis di tengah jalan — misalnya dari one-time license ke subscription — hampir selalu berarti refactor besar pada lapisan billing, autentikasi, dan manajemen akses.
Hybrid Model: Kombinasi yang Sering Berhasil
Banyak produk sukses menggunakan kombinasi model. Canva menggabungkan freemium dengan subscription (Canva Pro). Tokopedia menggabungkan marketplace fee dengan subscription (TopAds). Untuk startup Indonesia yang baru memulai, kombinasi paling pragmatis adalah freemium + subscription: gratis dengan batasan pengguna/proyek, bayar untuk membuka batas dan fitur premium. Ini menurunkan barrier akuisisi sekaligus memberi jalur konversi yang jelas.
| Kombinasi | Logika | Contoh |
|---|---|---|
| Freemium + Subscription | Akuisisi gratis, monetisasi power user | Notion, Canva, Slack |
| Subscription + Usage-Based | Base fee + overage untuk scaling | Twilio, SendGrid |
| License + Maintenance Fee | One-time + recurring kecil | SAP, Oracle (segmen enterprise) |
| Marketplace + Subscription | Platform fee + fitur premium seller | Tokopedia (TopAds + fee) |
Pricing Strategy
Harga bukan angka yang Anda tentukan berdasarkan intuisi atau rasa malu. Harga adalah sinyal kepada pasar tentang siapa pelanggan target Anda dan seberapa besar nilai yang Anda tawarkan. Harga terlalu rendah tidak hanya menyakiti margin — ia juga merusak persepsi kualitas. Harga terlalu tinggi menutup pintu untuk segmen yang sebenarnya bisa Anda layani. Menemukan titik harga yang tepat adalah eksperimen berbasis data, bukan keputusan sekali jadi.
Empat Pendekatan Penetapan Harga
1. Cost-Based Pricing (Berbasis Biaya)
Hitung total biaya operasional bulanan (server, bandwidth, layanan pihak ketiga, gaji tim proporsional), bagi dengan jumlah pelanggan target, tambahkan margin keuntungan. Pendekatan ini memastikan Anda tidak menjual rugi, namun kelemahannya adalah tidak memperhitungkan nilai yang diterima pelanggan. Jika produk Anda menghemat pelanggan Rp 10 juta per bulan, menjualnya seharga “biaya + 20%” yang hanya Rp 150.000 berarti Anda meninggalkan banyak uang di meja.
2. Value-Based Pricing (Berbasis Nilai)
Harga ditetapkan berdasarkan nilai ekonomis yang dirasakan pelanggan, bukan biaya Anda. Aplikasi payroll yang menggantikan 2 jam kerja admin setiap hari (setara Rp 1,5 juta/bulan gaji proporsional) bisa dihargai Rp 499.000/bulan dengan mudah. Kuncinya adalah memahami value metric: satuan pengukuran nilai yang paling logis bagi pelanggan (per pengguna, per transaksi, per faktur yang diterbitkan). Pendekatan ini menghasilkan harga tertinggi yang tetap bisa dijustifikasi kepada pelanggan.
3. Competitor-Based Pricing (Berbasis Kompetitor)
Riset harga kompetitor, lalu posisikan diri Anda: 10–20% di bawah untuk penetrasi pasar cepat, setara untuk persaingan langsung, atau di atas jika Anda punya diferensiasi nyata. Risiko: terperangkap dalam race to the bottom. Gunakan pendekatan ini sebagai sanity check, bukan sebagai referensi utama.
4. Tiered Pricing (Harga Bertingkat)
Ini adalah pendekatan paling umum dan paling efektif untuk SaaS. Anda menawarkan 3–4 paket dengan fitur dan harga berbeda, dirancang untuk melayani segmen pelanggan yang berbeda. Aturan praktis: paket tengah harus menjadi “pilihan terbaik” yang paling menarik secara nilai — ini yang paling banyak dipilih. Paket bawah sebagai entry point. Paket atas sebagai anchor yang membuat paket tengah terlihat murah.
Contoh Struktur Tier Harga (Aplikasi Manajemen UMKM Indonesia)
| Fitur | Gratis | Starter — Rp 149.000/bln | Pro — Rp 399.000/bln | Bisnis — Rp 999.000/bln |
|---|---|---|---|---|
| Jumlah pengguna | 1 | 3 | 10 | Tidak terbatas |
| Produk/SKU | 50 | 500 | Tidak terbatas | Tidak terbatas |
| Faktur per bulan | 20 | 200 | Tidak terbatas | Tidak terbatas |
| Laporan keuangan | Dasar | Dasar | Lengkap + ekspor | Lengkap + ekspor + custom |
| Integrasi marketplace | Tidak | Tidak | Tokopedia, Shopee | Semua + API akses |
| Multi-cabang | Tidak | Tidak | 2 cabang | Tidak terbatas |
| Dukungan | Email (72 jam) | Email (24 jam) | Chat (jam kerja) | Chat + telepon + SLA |
| White label | Tidak | Tidak | Tidak | Tersedia (add-on) |
Regional Pricing: Realitas Pasar Indonesia
Daya beli di Indonesia sangat heterogen. Startup teknologi di Jakarta Selatan bisa membayar Rp 500.000/bulan tanpa berpikir panjang, sementara UMKM di kabupaten kecil mungkin menganggap Rp 100.000/bulan sudah mahal. Regional pricing — menawarkan harga berbeda berdasarkan lokasi — adalah strategi yang valid namun kompleks secara teknis dan legal. Alternatif yang lebih sederhana: paket khusus segmen seperti “paket desa” atau “paket pesantren” dengan fitur yang dikurasi dan harga yang lebih rendah, tanpa mengubah tier utama.
Rumus. Harga Minimum yang Wajar = (Total Biaya Operasional Bulanan ÷ Target Pelanggan Berbayar) × 1.5. Ini memberi Anda margin 50% untuk pertumbuhan dan biaya tak terduga. Harga yang Optimal = nilai yang bisa Anda buktikan kepada pelanggan, biasanya 10–30% dari nilai ekonomis yang mereka dapatkan.
Psikologi Harga: Taktik yang Terbukti Efektif
| Taktik | Cara Kerja | Implementasi |
|---|---|---|
| Anchor Pricing | Tampilkan paket termahal pertama | Urutan tier: Bisnis → Pro → Starter |
| Charm Pricing | Harga berakhir 9 terasa lebih murah | Rp 399.000 bukan Rp 400.000 |
| Annual Discount | Diskon ~17% untuk bayar tahunan | Rp 3.990.000/tahun vs Rp 399.000×12 |
| Highlight “Most Popular” | Badge mendorong pilihan ke paket tengah | CSS badge kuning di kolom Pro |
| Free Trial | Kurangi risiko persepsi pelanggan baru | 14 hari trial tanpa kartu kredit |
Implementasi: Konfigurasi Plan di Kode
# config/plans.yml — definisi plan yang dibaca aplikasi saat startup
# Mengubah ini tidak perlu deploy ulang jika dimuat dari DB/config server
plans:
free:
name: "Gratis"
price_monthly: 0
price_yearly: 0
limits:
users: 1
products: 50
invoices_per_month: 20
branches: 1
features:
reports_advanced: false
marketplace_integration: false
api_access: false
white_label: false
export_excel: false
starter:
name: "Starter"
price_monthly: 149000
price_yearly: 1490000 # hemat ~Rp 298.000 vs bulanan
limits:
users: 3
products: 500
invoices_per_month: 200
branches: 1
features:
reports_advanced: false
marketplace_integration: false
api_access: false
white_label: false
export_excel: true
pro:
name: "Pro"
price_monthly: 399000
price_yearly: 3990000 # hemat ~Rp 798.000
badge: "Paling Populer"
limits:
users: 10
products: -1 # -1 berarti tidak terbatas
invoices_per_month: -1
branches: 2
features:
reports_advanced: true
marketplace_integration: true
api_access: true
white_label: false
export_excel: true
business:
name: "Bisnis"
price_monthly: 999000
price_yearly: 9990000
limits:
users: -1
products: -1
invoices_per_month: -1
branches: -1
features:
reports_advanced: true
marketplace_integration: true
api_access: true
white_label: true # tersedia sebagai add-on
export_excel: true
Tips. Jangan pernah mengubah harga tanpa komunikasi advance ke pelanggan eksisting. Di Indonesia, perubahan harga mendadak sangat merusak kepercayaan dan dapat memicu gelombang churn. Praktik terbaik: umumkan perubahan 60 hari sebelumnya, grandfathering harga lama untuk pelanggan aktif minimal 6–12 bulan.
Go-to-Market
Produk terbaik tidak selalu menang. Yang menang adalah produk yang paling baik ditemukan oleh orang yang tepat pada waktu yang tepat. Go-to-Market (GTM) adalah strategi terstruktur untuk menjawab tiga pertanyaan: siapa pelanggan pertama kita, bagaimana mereka menemukan kita, dan bagaimana kita mengubah mereka dari pengunjung menjadi pembayar. Untuk startup Indonesia, GTM yang efektif biasanya lebih bergantung pada kepercayaan komunitas dan word-of-mouth daripada iklan berbayar — karena biaya per akuisisi (CAC) dari iklan digital di Indonesia terus naik sementara kepercayaan terhadap brand baru masih rendah.
Tahapan GTM: Dari Nol Pengguna ke Traksi Awal
GTM bukan satu kejadian — ia adalah proses berkelanjutan. Namun ada urutan logis yang harus diikuti, terutama di fase awal.
Fase 1: Validasi Sebelum Launch (Minggu 1–4)
Sebelum membuang energi pada launch besar, validasi bahwa ada orang yang mau membayar. Interview minimal 20 calon pelanggan. Tunjukkan mockup atau MVP, bukan deskripsi abstrak. Catat objeksi spesifik. Jika kurang dari 5 dari 20 orang bersedia “pre-order” atau mendaftar ke waiting list, stop dan pivot dulu. Validasi ini jauh lebih murah daripada membangun fitur yang tidak diinginkan siapapun.
Fase 2: Landing Page & Waiting List (Minggu 2–6)
Landing page bukan website penuh — ia adalah satu halaman yang menjawab: apa produk ini, untuk siapa, apa manfaat utamanya, dan bagaimana mendaftar. Formula sederhana: Headline (manfaat, bukan fitur) + Social Proof + CTA + FAQ. Contoh headline yang efektif: “Kelola stok dan faktur UMKM Anda dalam satu aplikasi — tanpa perlu training akuntan” vs. yang buruk: “Sistem ERP terintegrasi berbasis cloud dengan fitur komprehensif”.
Fase 3: Product Hunt & Community Launch
Product Hunt adalah platform global untuk meluncurkan produk baru dan mendapatkan feedback dari komunitas tech early adopter. Untuk produk berbahasa Indonesia, dampaknya lebih ke validasi global dan press coverage daripada akuisisi pengguna langsung. Yang lebih efektif untuk pasar lokal: komunitas Telegram startup Indonesia, grup Facebook UMKM niche (pemilik toko online, salon, konveksi), dan forum-forum industri spesifik. Bergabunglah sebelum Anda punya produk — berikan nilai dulu, baru tawarkan produk.
Checklist Launch GTM
| # | Item | Siapa | Deadline | Status |
|---|---|---|---|---|
| 1 | Landing page live dengan CTA waiting list | Dev + Designer | T-30 hari | [ ] |
| 2 | Konten SEO: 3 artikel blog target keyword | Content | T-21 hari | [ ] |
| 3 | Social media accounts disiapkan (IG, LinkedIn, Twitter/X) | Marketing | T-21 hari | [ ] |
| 4 | Cold outreach ke 50 calon pelanggan target | Founder | T-14 hari | [ ] |
| 5 | Demo video produk (2–3 menit) | Dev + Founder | T-14 hari | [ ] |
| 6 | Onboarding email sequence (5 email) | Marketing + Dev | T-7 hari | [ ] |
| 7 | Beta tester (10–20 orang) sudah onboard | Founder | T-7 hari | [ ] |
| 8 | Sistem monitoring & alerting aktif | Dev | T-3 hari | [ ] |
| 9 | Press release ke media tech Indonesia | Founder | T-1 hari | [ ] |
| 10 | Posting di komunitas target (Telegram, FB Group) | Semua tim | Hari-H | [ ] |
Funnel Akuisisi: Dari Sadar hingga Bayar
Channel GTM yang Efektif untuk Startup Indonesia
Content Marketing & SEO
Tulis artikel yang menjawab pertanyaan spesifik calon pelanggan Anda di Google. Bukan “cara kelola stok” (terlalu generik), tapi “cara kelola stok toko online Shopee dan Tokopedia dalam satu aplikasi” (sangat spesifik). Buat konten yang menyelesaikan masalah nyata — bukan konten branding. Satu artikel panjang yang mendapat peringkat 1 Google untuk keyword dengan 500 pencarian/bulan lebih berharga daripada 20 postingan sosmed yang menghilang dalam 24 jam.
Cold Outreach Terstruktur
Cold email dan DM LinkedIn masih sangat efektif di Indonesia jika dilakukan dengan benar: personalisasi setiap pesan, referensikan masalah spesifik industri mereka, tawarkan demo gratis bukan langsung jualan. Target: pemilik bisnis dan manajer operasional, bukan IT manager (kecuali produk teknis). Rasio respons yang baik untuk cold outreach yang terpersonalisasi: 5–15%.
Partnership & Referral
Program referral adalah mesin pertumbuhan yang paling cost-efficient untuk pasar Indonesia yang sangat bergantung pada kepercayaan komunal. Struktur referral yang terbukti: komisi Rp 100.000–300.000 per pelanggan berbayar baru, atau diskon 1 bulan gratis untuk referrer dan referee. Partner strategis bisa berupa asosiasi UMKM, inkubator startup, akun Instagram yang melayani target segmen Anda, atau konsultan bisnis yang kliennya adalah calon pelanggan ideal Anda.
-- Tabel referral tracking
CREATE TABLE referral_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- siapa yang punya kode ini
code VARCHAR(20) NOT NULL UNIQUE,
reward_type VARCHAR(20) NOT NULL DEFAULT 'credit',
-- 'credit' | 'cash' | 'discount_month'
reward_amount BIGINT NOT NULL DEFAULT 100000, -- dalam Rupiah
total_referred INT NOT NULL DEFAULT 0,
total_earned BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE referral_conversions (
id BIGSERIAL PRIMARY KEY,
referral_code_id BIGINT NOT NULL,
referred_user_id BIGINT NOT NULL,
converted_at TIMESTAMPTZ, -- saat mereka jadi pelanggan berbayar
reward_paid_at TIMESTAMPTZ, -- saat komisi dibayarkan (after 30 days hold)
reward_amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending | confirmed | paid | reversed
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_referral_codes_user ON referral_codes(user_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_referral_codes_code ON referral_codes(code) WHERE deleted_at IS NULL;
CREATE INDEX idx_referral_conv_status ON referral_conversions(status, converted_at);
Awas. Jangan mencoba semua channel sekaligus. Fokus pada 1–2 channel yang paling relevan untuk segmen Anda, kuasai, ukur, baru ekspansi. Startup yang mencoba 6 channel dengan anggaran kecil biasanya tidak berhasil di satu pun.
Legal & Compliance untuk Produk Digital
Aspek legal sering diabaikan oleh developer sampai masalah datang — dan ketika masalah hukum datang, biayanya jauh lebih besar dari biaya pencegahan. Mulai dari gugatan hak cipta, sanksi UU ITE, hingga denda pajak yang tidak terduga, ketidaktahuan hukum tidak pernah menjadi pembelaan yang sah di pengadilan. Bab ini bukan pengganti konsultasi hukum profesional, namun memberikan peta jalan yang cukup untuk developer dan founder startup Indonesia memahami kewajiban hukum minimum yang harus dipenuhi sebelum produk digital diluncurkan ke publik.
Badan Hukum: PT vs CV untuk Startup
Memilih badan hukum yang tepat adalah keputusan pertama yang berdampak jangka panjang. Di Indonesia, dua pilihan utama untuk startup adalah PT (Perseroan Terbatas) dan CV (Commanditaire Vennootschap).
| Aspek | PT (Perseroan Terbatas) | CV (Commanditaire Vennootschap) |
|---|---|---|
| Tanggung jawab | Terbatas pada modal disetor — aset pribadi aman | Sekutu aktif bertanggung jawab penuh, sekutu pasif terbatas |
| Modal minimum | Rp 50 juta (UMK: bisa Rp 0 dengan catatan) | Tidak ada minimum formal |
| Pemegang saham | Minimal 2 orang/badan hukum | Minimal 2 orang (1 aktif, 1 pasif) |
| Investasi dari luar | Bisa menerima investor (saham) | Sangat sulit; tidak ada struktur saham |
| Biaya pendirian | Rp 3–10 juta (notaris + OSS) | Rp 1–3 juta (notaris + daftar) |
| Cocok untuk | Startup yang berencana terima investasi atau bertumbuh | Bisnis kecil tanpa rencana fundraising |
| Regulasi pajak | PPh Badan 22% (UMK: PP 55/2022 tarif 0.5% s/d 4.8M) | Dikenakan PPh orang pribadi pada sekutu aktif |
Rekomendasi tegas: gunakan PT jika Anda berencana menerima investasi eksternal, membangun tim, atau mengajukan tender ke pemerintah/BUMN. CV cocok hanya untuk freelancer atau bisnis gaya hidup dengan skala sangat kecil. Proses pendirian PT kini bisa dilakukan secara online melalui sistem OSS-RBA (Online Single Submission Risk Based Approach) di oss.go.id.
Hak Cipta Software di Indonesia
Berdasarkan UU No. 28 Tahun 2014 tentang Hak Cipta, software yang Anda tulis dilindungi hak cipta secara otomatis sejak diciptakan — tidak perlu pendaftaran. Namun pendaftaran resmi ke DJKI (Direktorat Jenderal Kekayaan Intelektual) memperkuat posisi hukum Anda dalam sengketa. Biaya pendaftaran: Rp 400.000 untuk UMKM melalui sistem e-HKI.
Poin kritis yang sering diabaikan: jika developer Anda adalah karyawan, kode yang mereka tulis dalam lingkup pekerjaan adalah hak cipta perusahaan. Jika developer adalah freelancer atau vendor eksternal, wajib ada klausul penyerahan hak cipta dalam kontrak kerja — tanpa klausul ini, kode milik developer, bukan Anda. Ini adalah sumber sengketa yang sangat umum.
Merek Dagang
Nama produk dan logo Anda bisa didaftarkan sebagai merek dagang ke DJKI. Biaya: sekitar Rp 1.800.000 per kelas per merek. Proses memakan waktu 12–18 bulan. Mengapa penting: tanpa merek terdaftar, kompetitor bisa mendaftarkan nama yang mirip dan Anda tidak punya kekuatan hukum untuk menghalanginya. Cek ketersediaan nama di situs DJKI sebelum launch publik.
Terms of Service & Privacy Policy
ToS dan Privacy Policy bukan sekadar formalitas — mereka adalah kontrak antara Anda dan pengguna. ToS menentukan apa yang boleh dan tidak boleh dilakukan pengguna di platform Anda, bagaimana Anda menangani pelanggaran, dan batasan tanggung jawab Anda. Privacy Policy menjelaskan data apa yang dikumpulkan, bagaimana digunakan, dan bagaimana dilindungi.
| Klausa Penting ToS | Isi Minimum |
|---|---|
| Definisi layanan | Apa yang Anda sediakan dan apa yang tidak |
| Kewajiban pengguna | Larangan penyalahgunaan, akun palsu, scraping |
| Hak kekayaan intelektual | Konten pengguna: siapa pemiliknya; lisensi yang Anda butuhkan |
| Pembatasan tanggung jawab | Kerugian tidak langsung tidak ditanggung platform |
| Penghentian layanan | Hak Anda menangguhkan akun; prosedur banding |
| Perubahan ketentuan | Notifikasi 30 hari sebelum perubahan material |
| Hukum yang berlaku | Hukum Indonesia; yurisdiksi pengadilan Jakarta |
UU ITE & UU PDP: Kewajiban Minimum Aplikasi
Dua regulasi yang paling relevan untuk produk digital Indonesia saat ini adalah UU No. 19 Tahun 2016 (revisi UU ITE) dan UU No. 27 Tahun 2022 tentang Pelindungan Data Pribadi (UU PDP).
UU PDP mewajibkan operator sistem elektronik (yaitu Anda, sebagai penyedia aplikasi) untuk:
- Memperoleh consent eksplisit sebelum mengumpulkan data pribadi
- Memberikan hak kepada subjek data untuk mengakses, memperbaiki, dan menghapus data mereka
- Melaporkan pelanggaran data (data breach) kepada Kominfo dan subjek data dalam 14 hari kerja
- Menerapkan langkah-langkah keamanan teknis dan organisasional yang memadai
-- Implementasi teknis: consent tracking sesuai UU PDP
CREATE TABLE user_consents (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
consent_type VARCHAR(50) NOT NULL,
-- 'marketing_email' | 'data_processing' | 'third_party_sharing' | 'analytics'
version VARCHAR(20) NOT NULL, -- versi privacy policy saat consent
given_at TIMESTAMPTZ, -- NULL jika belum atau sudah ditarik
withdrawn_at TIMESTAMPTZ,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_consents_user_type ON user_consents(user_id, consent_type);
-- Prosedur hak hapus data (Right to Erasure / Pasal 35 UU PDP)
-- Ini menjalankan pseudonymization, bukan hard delete fisik
-- agar audit log tetap valid
CREATE OR REPLACE FUNCTION anonymize_user_data(p_user_id BIGINT)
RETURNS VOID AS $$
BEGIN
-- Anonimisasi data identitas
UPDATE users SET
name = 'DELETED_USER_' || p_user_id,
email = 'deleted_' || p_user_id || '@anonymized.invalid',
phone = NULL,
address = NULL,
avatar_url = NULL,
deleted_at = NOW(),
updated_at = NOW()
WHERE id = p_user_id;
-- Catat permintaan penghapusan untuk audit
INSERT INTO data_deletion_requests (user_id, requested_at, completed_at, status)
VALUES (p_user_id, NOW(), NOW(), 'completed');
-- Cabut semua session aktif
DELETE FROM user_sessions WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;
Perpajakan Digital
Startup digital Indonesia wajib memahami dua jenis pajak utama:
PPN (Pajak Pertambahan Nilai): Jika omzet Anda melebihi Rp 4,8 miliar per tahun, Anda wajib dikukuhkan sebagai PKP (Pengusaha Kena Pajak) dan memungut PPN 11% dari setiap transaksi. Untuk produk SaaS yang dijual ke pelanggan B2B, umumnya PPN harus dibebankan terpisah dan dilaporkan melalui faktur pajak elektronik. Untuk pelanggan konsumen akhir (B2C), PPN biasanya sudah termasuk dalam harga yang ditampilkan.
PPh (Pajak Penghasilan): PT membayar PPh Badan 22% dari laba kena pajak. UMKM (omzet di bawah Rp 4,8 miliar) dapat menggunakan PPh Final 0,5% dari omzet bruto berdasarkan PP 55 Tahun 2022. Selain itu, Anda wajib memotong PPh 23 (2%) atas pembayaran ke vendor jasa tertentu dan PPh 21 atas gaji karyawan.
Awas. Banyak startup Indonesia lalai mendaftarkan NPWP perusahaan dan tidak melaporkan pajak sejak awal karena omzet masih kecil. Ini adalah kesalahan fatal — denda keterlambatan dan bunga pajak bisa jauh melebihi pajak yang terutang, dan riwayat pajak yang bersih adalah syarat wajib untuk due diligence investasi.
Exit Strategy
Exit bukan kata yang menyedihkan — ia adalah salah satu tujuan yang paling logis dan sering diabaikan dalam membangun startup. Memikirkan exit strategy sejak awal bukan berarti Anda tidak berkomitmen pada produk; justru sebaliknya, ia memaksa Anda untuk membangun sesuatu yang benar-benar bernilai bagi orang lain, bukan hanya bagi diri sendiri. Investor memasukkan uang dengan ekspektasi exit. Founder mungkin ingin fokus pada proyek berikutnya. Tim ingin kepastian karier. Memahami opsi exit — kapan, bagaimana, dan pada valuasi berapa — adalah bagian dari membangun perusahaan yang serius.
Empat Jalur Exit Utama
1. Akuisisi (M&A — Mergers & Acquisitions)
Perusahaan lain membeli bisnis Anda — baik seluruhnya maupun sebagian. Ini adalah jalur exit paling umum untuk startup Indonesia. Pembeli bisa berupa kompetitor yang ingin mengeliminasi atau mengakuisisi pasar Anda, perusahaan besar yang ingin membeli teknologi atau tim Anda (acqui-hire), atau konglomerat yang ingin masuk ke segmen digital. Valuasi dalam akuisisi biasanya berbasis kelipatan ARR (untuk SaaS), EBITDA (untuk bisnis profitabel), atau strategic value (jika pembeli sangat menginginkan teknologi atau pengguna Anda).
2. IPO (Initial Public Offering)
Menawarkan saham kepada publik melalui bursa efek. Di Indonesia, Bursa Efek Indonesia (BEI) membuka jalur listing untuk perusahaan teknologi. IPO membutuhkan perusahaan yang sudah matang: revenue yang konsisten, tata kelola yang baik, laporan keuangan yang diaudit minimal 3 tahun, dan tim manajemen yang kuat. Proses IPO memakan waktu 12–24 bulan dan biaya yang tidak kecil (underwriter, konsultan hukum, akuntan publik). Untuk startup early-stage, IPO adalah tujuan jangka panjang — bukan opsi dalam 1–3 tahun pertama.
3. Bootstrap Forever (Lifestyle Business)
Tidak semua exit harus berupa penjualan. Jika bisnis Anda menghasilkan profit yang baik dan Anda puas dengan posisinya, “not exiting” adalah exit strategy yang valid. Basecamp (DHH dan Jason Fried) adalah contoh terkenal: menolak akuisisi berkali-kali, tetap profitabel selama lebih dari 20 tahun, dan memberikan gaya hidup yang sangat baik bagi pendiri dan karyawannya. Di Indonesia, banyak SaaS lokal yang menguntungkan beroperasi di model ini dengan tenang.
4. Jual Sistem / Franchise Model
Anda menjual hak penggunaan sistem (white label) atau franchise bisnis kepada pihak lain yang akan menjalankannya secara mandiri. Ini berbeda dari akuisisi: Anda tidak menjual perusahaan, tapi menjual model bisnis. Untuk produk software, ini bisa berupa penjualan lisensi white-label masif ke reseller regional. Setiap reseller membayar upfront fee + royalti tahunan untuk menjalankan platform Anda dengan brand mereka sendiri.
Tabel Perbandingan Jalur Exit
| Jalur Exit | Timeline Realistis | Valuasi Tipikal | Kompleksitas | Cocok Jika |
|---|---|---|---|---|
| Akuisisi strategis | 3–7 tahun | 3–10× ARR (SaaS); 5–15× EBITDA | Tinggi (due diligence, negosiasi) | Ada pembeli yang butuh teknologi/pasar Anda |
| Acqui-hire | 1–4 tahun | Rp 500 juta–5 miliar (berbasis tim) | Sedang | Tim kuat, produk belum skala besar |
| IPO BEI | 7–15 tahun | 15–40× EBITDA atau revenue | Sangat tinggi | Revenue besar, tata kelola matang |
| Bootstrap forever | Tidak ada deadline | Tidak relevan (dividend stream) | Rendah | Profitabel, tidak butuh exit likuiditas |
| White label / franchise | 2–5 tahun | Rp 2–20 miliar (bergantung sistem) | Sedang | Model bisnis proven, margin tinggi |
Cara Menghitung Valuasi Startup SaaS Indonesia
Valuasi bukan angka ajaib — ia adalah fungsi dari metrik bisnis yang terukur. Untuk startup SaaS tahap awal hingga menengah, dua metode valuasi yang paling sering digunakan dalam konteks Indonesia adalah kelipatan ARR dan metode DCF sederhana.
# Contoh kalkulasi valuasi SaaS (Python — bisa dijalankan di notebook)
# --- Data perusahaan (fiktif, representatif untuk SaaS B2B Indonesia) ---
mrr = 125_000_000 # Monthly Recurring Revenue: Rp 125 juta
arr = mrr * 12 # Annual Recurring Revenue: Rp 1,5 miliar
monthly_churn_rate = 0.03 # 3% churn bulanan (cukup tinggi, target <2%)
growth_rate_monthly = 0.08 # 8% pertumbuhan MRR per bulan
# --- Metode 1: ARR Multiple ---
# Kelipatan ARR untuk SaaS Indonesia: 2-5x (mature market: 5-10x)
arr_multiple_low = 2.5
arr_multiple_high = 4.0
valuation_arr_low = arr * arr_multiple_low
valuation_arr_high = arr * arr_multiple_high
print("=== VALUASI BERBASIS ARR MULTIPLE ===")
print(f"ARR: Rp {arr:,.0f}")
print(f"Valuasi rendah (2.5x): Rp {valuation_arr_low:,.0f}")
print(f"Valuasi tinggi (4.0x): Rp {valuation_arr_high:,.0f}")
# --- Metode 2: LTV/CAC Ratio sebagai indikator kesehatan ---
avg_revenue_per_user_monthly = 350_000 # Rp 350.000/bulan
avg_lifetime_months = 1 / monthly_churn_rate # 33.3 bulan
ltv = avg_revenue_per_user_monthly * avg_lifetime_months
cac = 1_200_000 # Biaya akuisisi per pelanggan: Rp 1,2 juta
ltv_cac_ratio = ltv / cac
print("\n=== KESEHATAN UNIT ECONOMICS ===")
print(f"LTV: Rp {ltv:,.0f}")
print(f"CAC: Rp {cac:,.0f}")
print(f"LTV/CAC Ratio: {ltv_cac_ratio:.1f}x (Target: >3x, Baik: >5x)")
print(f"CAC Payback Period: {cac / avg_revenue_per_user_monthly:.1f} bulan (Target: <12 bulan)")
# --- Metode 3: Rule of 40 (Growth Rate + Profit Margin) ---
annual_growth_rate_pct = (growth_rate_monthly * 12) * 100 # approx
profit_margin_pct = -15 # masih bakar uang 15%
rule_of_40_score = annual_growth_rate_pct + profit_margin_pct
print("\n=== RULE OF 40 ===")
print(f"Pertumbuhan tahunan: {annual_growth_rate_pct:.1f}%")
print(f"Profit margin: {profit_margin_pct}%")
print(f"Rule of 40 score: {rule_of_40_score:.1f} (Target: >40)")
# --- Output contoh ---
# === VALUASI BERBASIS ARR MULTIPLE ===
# ARR: Rp 1,500,000,000
# Valuasi rendah (2.5x): Rp 3,750,000,000
# Valuasi tinggi (4.0x): Rp 6,000,000,000
#
# === KESEHATAN UNIT ECONOMICS ===
# LTV: Rp 11,666,667
# CAC: Rp 1,200,000
# LTV/CAC Ratio: 9.7x (Target: >3x, Baik: >5x)
# CAC Payback Period: 3.4 bulan (Target: <12 bulan)
#
# === RULE OF 40 ===
# Pertumbuhan tahunan: 96.0%
# Profit margin: -15%
# Rule of 40 score: 81.0 (Target: >40)
Mempersiapkan Diri untuk Due Diligence
Ketika pembeli serius muncul, mereka akan melakukan due diligence — pemeriksaan menyeluruh terhadap bisnis Anda sebelum menandatangani SPA (Sale and Purchase Agreement). Persiapkan ini jauh sebelum dibutuhkan:
| Area Due Diligence | Dokumen yang Dibutuhkan | Risiko Jika Tidak Siap |
|---|---|---|
| Keuangan | Laporan keuangan 3 tahun teraudit, rekening koran, MRR/ARR history | Valuasi turun, deal batal |
| Legal | Akta PT, NPWP, NIB, semua kontrak pelanggan & vendor | Blokir transaksi, representasi warranty breach |
| Pajak | SPT 3 tahun terakhir, bukti setor PPN & PPh | Escrow pajak atau price adjustment |
| Teknis | Dokumentasi arsitektur, dependency list, SLA record, security audit | Technical risk discount pada valuasi |
| IP (Hak Cipta) | Kontrak developer dengan klausul IP assignment, DJKI registrasi | Pembeli tidak bisa mendapat kepastian kepemilikan IP |
| SDM | Kontrak karyawan dengan NDA & non-compete, struktur organisasi | Key person risk — valuasi turun jika bergantung 1 orang |
Inti. Bangun bisnis seolah Anda akan mempertahankannya selamanya, tapi dokumentasikan seolah-olah Anda akan menjualnya besok. Dokumentasi yang baik, laporan keuangan yang bersih, dan kode yang terdokumentasi bukan hanya membuat due diligence lancar — mereka juga membuat bisnis Anda lebih mudah dioperasikan dan diskala oleh siapapun, termasuk diri Anda sendiri lima tahun ke depan.
Tanda-Tanda Waktu yang Tepat untuk Exit via Akuisisi
Tidak ada formula pasti, namun beberapa sinyal menunjukkan timing yang baik untuk mempertimbangkan tawaran akuisisi:
- Anda mendapat inbound dari acquirer — artinya mereka sudah riset dan serius
- Pertumbuhan organik mulai melambat dan butuh modal besar untuk tahap berikutnya
- Acquirer memiliki distribusi yang bisa mempercepat pertumbuhan produk Anda 5–10×
- Valuasi pasar sedang tinggi (bull market untuk tech) — timing matters
- Founder burn out dan tim membutuhkan resources yang lebih besar dari yang bisa Anda sediakan
Tips. Jangan menunggu tawaran datang baru memikirkan valuasi. Hitung valuasi Anda setiap kuartal menggunakan metrik yang sama yang akan digunakan acquirer. Ini membuat Anda siap bernegosiasi dari posisi yang kuat — dan sering kali, membantu Anda mengetahui kapan belum saatnya menjual.
Lampiran & Referensi
Template MD Document
Market Discovery (MD) Document adalah artefak paling penting sebelum satu baris kode pun ditulis. Dokumen ini memaksa founder dan tim teknis menyepakati siapa yang dilayani, masalah apa yang dipecahkan, dan ukuran keberhasilan yang bisa diverifikasi. Template berikut siap disalin, diisi, dan dikomit ke repositori sebagai docs/market-discovery.md.
Aturan pakai. Isi setiap bagian sebelum sprint pertama dimulai. Bagian yang kosong berarti asumsi yang belum divalidasi — dan asumsi yang belum divalidasi adalah hutang teknis sebelum kode ada.
# Market Discovery Document
# Proyek: [NAMA PRODUK]
# Versi: 1.0
# Tanggal: YYYY-MM-DD
# Owner: [Nama / Tim]
---
## 1. Problem Statement
### 1.1 Masalah Inti
> Tuliskan masalah dalam 1–2 kalimat. Spesifik, bukan generik.
> Buruk: "Orang kesulitan mengelola keuangan."
> Baik: "UMKM kuliner di kota tier-2 tidak memiliki cara sistematis untuk
> melacak stok bahan baku secara real-time sehingga sering over-order
> atau kehabisan di jam sibuk."
**Masalah:** [ISI DI SINI]
### 1.2 Akar Penyebab (Root Cause)
- [ ] Kurangnya tools yang terjangkau
- [ ] Proses manual yang error-prone
- [ ] Kurangnya visibilitas data
- [ ] Lainnya: ___
### 1.3 Dampak Jika Tidak Diselesaikan
- Kerugian finansial: Rp ___ / bulan rata-rata per pelanggan
- Waktu terbuang: ___ jam / minggu
- Risiko operasional: ___
---
## 2. Target Pengguna
### 2.1 Segmen Utama
| Atribut | Deskripsi |
|------------------|------------------------------------|
| Profil | [contoh: Pemilik UMKM kuliner] |
| Lokasi | [contoh: Kota tier-2 Indonesia] |
| Skala bisnis | [contoh: 1–5 karyawan, omzet <Rp 50jt/bulan] |
| Akses teknologi | [contoh: Android, internet 4G] |
| Pain paling besar| [contoh: stok hilang tidak terdeteksi] |
### 2.2 Persona
**Nama fiktif:** [contoh: Bu Rina, 38 tahun]
**Kutipan khas:** "[Kalimat yang sering diucapkan persona ini tentang masalahnya]"
**Hari dalam hidupnya:** [1 paragraf cerita hari kerja tipikal persona]
**Frustrasi utama:** [3 poin bullets]
**Yang diinginkan:** [3 poin bullets]
### 2.3 Segmen Sekunder (opsional)
[Siapa lagi yang diuntungkan walau bukan target utama]
---
## 3. Solusi yang Diusulkan
### 3.1 Deskripsi Singkat (Elevator Pitch)
> [Nama Produk] adalah [kategori] untuk [target pengguna]
> yang [masalah yang diselesaikan]. Berbeda dengan [alternatif saat ini],
> kami [keunggulan utama].
### 3.2 Fitur MVP (Harus Ada)
| # | Fitur | Alasan Wajib |
|---|------------------------|-------------------------------------|
| 1 | [nama fitur] | [kenapa ini inti, bukan nice-to-have] |
| 2 | [nama fitur] | [...] |
| 3 | [nama fitur] | [...] |
### 3.3 Fitur Fase 2 (Jangan Bangun Sekarang)
- [ ] [fitur]
- [ ] [fitur]
### 3.4 Out of Scope (Tidak Akan Dibangun)
- [fitur yang sengaja dikecualikan dan alasannya]
---
## 4. Validasi Pasar
### 4.1 Riset yang Sudah Dilakukan
| Metode | Jumlah Responden | Temuan Utama |
|---------------------|------------------|-----------------------------|
| Wawancara pengguna | [N] orang | [...] |
| Survey online | [N] responden | [...] |
| Observasi lapangan | [N] bisnis | [...] |
| Competitor analysis | [N] produk | [...] |
### 4.2 Bukti Demand (Traction Early)
- Waiting list: [N] email terdaftar
- LOI (Letter of Intent): [N] bisnis bersedia bayar
- Pilot user: [N] bisnis aktif menggunakan prototype
### 4.3 Asumsi Kritis yang Belum Divalidasi
| Asumsi | Cara Validasi | Deadline |
|-------------------------------------|-------------------------|-------------|
| [contoh: pengguna mau input data manual] | [A/B test onboarding] | [YYYY-MM-DD] |
| [asumsi 2] | [metode] | [tanggal] |
---
## 5. Kompetitor & Alternatif
### 5.1 Peta Kompetitor
| Nama | Segmen | Harga/bulan | Kelemahan Utama |
|---------------------|-------------|-------------|-------------------------|
| [Kompetitor A] | [B2B/B2C] | Rp ___ | [kelemahan relevan] |
| [Kompetitor B] | [...] | Rp ___ | [...] |
| Spreadsheet manual | Semua | Gratis | Error, tidak real-time |
| WhatsApp group | UMKM | Gratis | Tidak terstruktur |
### 5.2 Competitive Advantage
- [Keunggulan 1: spesifik dan terukur]
- [Keunggulan 2]
- [Keunggulan 3]
---
## 6. Metrik Keberhasilan
### 6.1 Metrik Produk (Teknis)
| Metrik | Target Bulan 1 | Target Bulan 3 | Target Bulan 6 |
|---------------------|---------------|----------------|----------------|
| Pengguna aktif harian (DAU) | [N] | [N] | [N] |
| Retention D7 | >30% | >40% | >50% |
| Error rate API | <0.5% | <0.3% | <0.1% |
| Latency P95 | <500ms | <300ms | <200ms |
### 6.2 Metrik Bisnis
| Metrik | Target Bulan 1 | Target Bulan 3 | Target Bulan 6 |
|---------------------|---------------|----------------|----------------|
| MRR | Rp ___ | Rp ___ | Rp ___ |
| Paying customers | [N] | [N] | [N] |
| CAC | Rp ___ | Rp ___ | Rp ___ |
| Churn rate | <10% | <7% | <5% |
---
## 7. Risiko & Mitigasi
| Risiko | Probabilitas | Dampak | Mitigasi |
|---------------------------------|-------------|----------|---------------------------------|
| [contoh: rendahnya adopsi awal] | Tinggi | Tinggi | [onboarding concierge + pilot] |
| [risiko teknis utama] | Sedang | Tinggi | [...] |
| [risiko regulasi/legal] | Rendah | Sangat tinggi | [...] |
---
## 8. Rencana Teknis Tinggi (High-Level)
### 8.1 Stack Teknologi
- **Frontend:** [contoh: Next.js 14, TypeScript, Tailwind CSS]
- **Backend:** [contoh: Laravel 11, PHP 8.3]
- **Database:** [contoh: PostgreSQL 16 — NO FK, soft delete, audit trail]
- **Cache/Queue:** [contoh: Redis 7]
- **Deploy:** [contoh: VPS Ubuntu 22.04, Docker Compose, Nginx]
### 8.2 Timeline Sprint
| Sprint | Durasi | Deliverable |
|--------|---------|------------------------------------|
| 0 | 1 minggu| Setup infra, CI/CD, skeleton code |
| 1 | 2 minggu| Auth + modul inti #1 |
| 2 | 2 minggu| Modul inti #2 + #3 |
| 3 | 2 minggu| Polish, testing, staging |
| 4 | 1 minggu| Launch + monitoring |
---
## 9. Tanda Tangan & Persetujuan
| Peran | Nama | Tanda Tangan | Tanggal |
|---------------|---------------|--------------|--------------|
| Product Owner | ___ | ___ | YYYY-MM-DD |
| Tech Lead | ___ | ___ | YYYY-MM-DD |
| Designer | ___ | ___ | YYYY-MM-DD |
---
*Dokumen ini hidup (living document). Update setiap kali ada pivot signifikan.*
*Versi ditrack di Git — lihat `git log docs/market-discovery.md`.*
Tips. Simpan dokumen ini di
docs/market-discovery.mddalam repositori yang sama dengan kode. Dengan begitu, setiap PR yang mengubah arah produk bisa mereferensikan perubahan di dokumen ini, menciptakan audit trail yang menjembatani keputusan bisnis dan keputusan teknis.
Template Business Model Canvas
Business Model Canvas (BMC) adalah alat visualisasi satu halaman yang merangkum seluruh logika bisnis — dari siapa pelanggan Anda hingga bagaimana uang mengalir masuk dan keluar. Dibuat oleh Alexander Osterwalder, BMC jauh lebih cepat diisi dan direvisi dibandingkan business plan tradisional 40 halaman, sehingga cocok untuk startup yang bergerak cepat dan sering pivot.
Rumus. BMC terdiri dari 9 blok yang saling terkait. Isi dari kanan ke kiri: mulai dari Customer Segments → Value Propositions → Channels → Customer Relationships → Revenue Streams, lalu kembali ke kiri: Key Resources → Key Activities → Key Partnerships → Cost Structure.
Penjelasan Tiap Blok
Blok 1 — Customer Segments (Segmen Pelanggan)
Siapa yang dilayani? Jangan jawab “semua orang” — itu bukan segmen. Pilih satu segmen primer dan maksimal dua segmen sekunder untuk MVP. Gunakan atribut spesifik: industri, ukuran perusahaan, lokasi geografis, perilaku digital. Contoh buruk: “UKM Indonesia.” Contoh baik: “Pemilik warung makan dengan 1–3 cabang di kota tier-2, omzet Rp 30–150 juta/bulan, menggunakan Android, belum punya sistem kasir digital.”
Blok 2 — Value Propositions (Proposisi Nilai)
Apa yang membuat pelanggan memilih Anda dibanding alternatif yang sudah ada — termasuk “tidak melakukan apa-apa”? Proposisi nilai yang kuat menyebutkan masalah spesifik dan kuantifikasi manfaatnya. “Hemat 3 jam per hari dari proses rekap stok manual” jauh lebih kuat dari “sistem manajemen stok yang mudah.”
Blok 3 — Channels (Saluran)
Bagaimana pelanggan menemukan, membeli, dan menerima value dari Anda? Channels mencakup awareness (bagaimana mereka tahu?), evaluation (bagaimana mereka mencoba?), purchase (bagaimana mereka bayar?), delivery (bagaimana mereka mendapat akses?), dan after-sales (bagaimana mereka dapat bantuan?). Untuk startup B2B Indonesia tahap awal, saluran paling efektif biasanya: komunitas WhatsApp pengusaha lokal, referral dari pengguna awal, dan presentasi langsung ke komunitas UMKM.
Blok 4 — Customer Relationships (Relasi Pelanggan)
Bagaimana Anda membangun dan mempertahankan hubungan dengan pelanggan? Pilihan umum: self-service (dokumentasi, video tutorial — biaya rendah, cocok untuk SaaS B2C), personal assistance (customer success manager — cocok B2B enterprise), automated (email drip, in-app nudge), atau community (forum pengguna, grup WhatsApp). Untuk UMKM Indonesia, respons cepat via WhatsApp seringkali lebih penting dari dokumentasi lengkap.
Blok 5 — Revenue Streams (Aliran Pendapatan)
Dari apa Anda menghasilkan uang, dan bagaimana harganya ditentukan? Bedakan antara one-time revenue (setup fee, lisensi) dan recurring revenue (subscription). Recurring selalu lebih baik untuk valuasi dan prediksi bisnis. Riset harga kompetitor lokal sebelum menentukan harga — pelanggan UMKM Indonesia sangat sensitif harga, terutama di tier Starter.
Blok 6 — Key Resources (Sumber Daya Kunci)
Aset apa yang paling kritis untuk menjalankan model bisnis ini? Untuk startup teknologi: aset intelektual (codebase, algoritma, data), sumber daya manusia (tim teknis, tim sales), infrastruktur fisik/digital (server, domain), dan finansial (modal, kredit). Identifikasi sumber daya mana yang paling sulit digantikan jika hilang.
Blok 7 — Key Activities (Aktivitas Kunci)
Apa yang paling penting dilakukan perusahaan setiap hari agar value proposition bisa diterima pelanggan? Untuk SaaS: pengembangan produk berkelanjutan, customer support berkualitas, dan akuisisi pelanggan baru. Jangan masukkan semua aktivitas operasional — hanya yang benar-benar membedakan Anda dari kompetitor.
Blok 8 — Key Partnerships (Kemitraan Kunci)
Mitra mana yang membuat model bisnis ini berfungsi dan Anda tidak bisa atau tidak mau melakukannya sendiri? Tiga jenis mitra umum: supplier (cloud provider, payment gateway), mitra strategis (asosiasi industri, distributor), dan co-opetition (kolaborasi dengan kompetitor untuk memperbesar pasar). Hindari terlalu banyak dependensi pada satu mitra kritis — itu risiko operasional.
Blok 9 — Cost Structure (Struktur Biaya)
Biaya terbesar apa yang diakibatkan oleh model bisnis ini? Bedakan cost-driven (minimasi biaya — cocok untuk kompetisi harga) vs value-driven (fokus pada value premium — cocok untuk enterprise). Untuk startup tahap awal, identifikasi burn rate (total pengeluaran per bulan) dan runway (berapa bulan bisa beroperasi dengan modal saat ini) agar Anda tahu kapan harus mencapai break-even.
Awas. BMC bukan dokumen sekali isi dan dilupakan. Revisi BMC setiap kali ada pembelajaran signifikan dari pelanggan — minimal setiap akhir sprint atau setelah sesi wawancara pengguna. Tandai versi dengan tanggal dan simpan di repositori untuk melacak evolusi model bisnis Anda.
Template SWOT
Analisis SWOT — Strengths, Weaknesses, Opportunities, Threats — adalah alat strategis yang membantu Anda memetakan posisi produk secara jujur sebelum masuk pasar. Kuncinya adalah kejujuran: banyak founder mengisi SWOT dengan bias konfirmasi, membesar-besarkan kekuatan dan mengecilkan ancaman. SWOT yang berguna adalah yang menyakitkan untuk dibaca.
Inti. Strengths dan Weaknesses bersifat internal (dalam kendali Anda). Opportunities dan Threats bersifat eksternal (terjadi di luar, Anda hanya bisa bereaksi). Jangan mencampur keduanya — itu membuat analisis tidak dapat ditindaklanjuti.
Panduan Pengisian SWOT yang Efektif
| Kuadran | Pertanyaan Pemandu | Kesalahan Umum | Tindak Lanjut |
|---|---|---|---|
| Strengths | Apa yang pelanggan memuji dari kita? Apa keunggulan yang sulit ditiru kompetitor dalam 6 bulan? | Mengisi dengan hal yang semua orang punya (misal: “tim berdedikasi”) | Perkuat dan jadikan bagian dari value proposition |
| Weaknesses | Apa yang sering dikeluhkan pelanggan? Di mana kita kalah dari kompetitor? | Menyembunyikan kelemahan atau mengubahnya jadi kekuatan semu | Buat rencana mitigasi atau pivot jika fatal |
| Opportunities | Tren pasar apa yang menguntungkan kita? Celah apa yang belum diisi kompetitor? | Mengisi dengan harapan, bukan fakta pasar yang terverifikasi | Prioritaskan peluang dengan potensi revenue tertinggi dan effort terendah |
| Threats | Siapa yang bisa menghancurkan bisnis kita dalam 12 bulan? Regulasi apa yang sedang berubah? | Mengabaikan ancaman dari pemain besar yang belum masuk segmen ini | Bangun moat (parit pertahanan): komunitas, data, switching cost |
Tips. Setelah SWOT selesai, lakukan analisis SO-ST-WO-WT: bagaimana kekuatan bisa memanfaatkan peluang (SO)? Bagaimana kekuatan bisa menangkal ancaman (ST)? Bagaimana peluang bisa menutup kelemahan (WO)? Bagaimana meminimalkan kelemahan dan ancaman secara bersamaan (WT)? Empat kuadran analisis lanjutan ini adalah tempat strategi nyata lahir.
Cheat Sheet API Design
Konsistensi adalah nilai paling kritis dalam desain API. Tim frontend tidak boleh menebak-nebak apakah endpoint menggunakan /getUser atau /users/{id}, apakah error dikembalikan dengan status 200 atau 422, atau apakah pagination menggunakan page atau offset. Cheat sheet ini adalah referensi cepat untuk memastikan seluruh tim mengikuti konvensi yang sama dari hari pertama.
HTTP Method & Semantik
| Method | Semantik | Idempoten? | Body? | Contoh Penggunaan |
|---|---|---|---|---|
GET |
Baca resource | Ya | Tidak | Ambil daftar produk, detail user |
POST |
Buat resource baru | Tidak | Ya | Buat order, registrasi user |
PUT |
Ganti seluruh resource | Ya | Ya | Update profil lengkap (semua field) |
PATCH |
Update sebagian field | Tidak selalu | Ya | Ubah status order, update email saja |
DELETE |
Hapus resource | Ya | Opsional | Soft delete produk |
HTTP Status Code Wajib Hafal
| Kode | Nama | Kapan Digunakan |
|---|---|---|
200 |
OK | GET/PUT/PATCH berhasil, ada response body |
201 |
Created | POST berhasil membuat resource baru |
204 |
No Content | DELETE berhasil, tidak ada body |
400 |
Bad Request | Input tidak valid (format salah, field wajib kosong) |
401 |
Unauthorized | Tidak ada token atau token expired |
403 |
Forbidden | Token valid tapi tidak punya izin resource ini |
404 |
Not Found | Resource tidak ditemukan |
409 |
Conflict | Duplicate data (email sudah terdaftar, slug sudah ada) |
422 |
Unprocessable Entity | Format valid tapi gagal validasi bisnis (stok tidak cukup) |
429 |
Too Many Requests | Rate limit terlampaui |
500 |
Internal Server Error | Bug server yang tidak terduga — jangan bocorkan detail |
Naming Convention URL
| Aturan | Benar | Salah |
|---|---|---|
| Gunakan kata benda jamak | /users, /products |
/user, /getProduct |
| Huruf kecil + kebab-case | /product-categories |
/ProductCategories, /product_categories |
| Resource ID di path | /users/{id} |
/getUserById?id=123 |
| Sub-resource nested (maks 2 level) | /orders/{id}/items |
/orders/{id}/items/{itemId}/details/notes |
| Action non-CRUD pakai kata kerja | POST /orders/{id}/cancel |
DELETE /orders/{id}/cancel |
| Versi di prefix | /api/v1/users |
/api/users/v1 |
| Filter via query string | /products?category=makanan&status=active |
/products/makanan/active |
Contoh Endpoint Lengkap — Modul Produk
# Base URL: https://api.namaapp.id/api/v1
# Auth: Bearer token di header Authorization
# ── CRUD Produk ──────────────────────────────────────────────────
GET /products # Daftar produk (paginated)
GET /products/{id} # Detail satu produk
POST /products # Buat produk baru
PATCH /products/{id} # Update sebagian field
DELETE /products/{id} # Soft delete
# ── Filter & Sort ─────────────────────────────────────────────────
GET /products?page=1&per_page=15&sort=name&direction=asc
GET /products?category_id=3&status=active&search=kopi
# ── Sub-resource ──────────────────────────────────────────────────
GET /products/{id}/variants # Varian produk
POST /products/{id}/variants # Tambah varian
PATCH /products/{id}/variants/{variantId}
# ── Action non-CRUD ───────────────────────────────────────────────
POST /products/{id}/restore # Restore soft-deleted
POST /products/{id}/duplicate # Duplikat produk
POST /products/bulk-delete # Hapus banyak sekaligus
# ── Upload ────────────────────────────────────────────────────────
POST /products/{id}/images # Upload gambar (multipart)
DELETE /products/{id}/images/{imageId}
Format Response Standar
// Success — single resource
{
"status": "success",
"data": {
"id": "prod_01HXYZ",
"name": "Kopi Arabica 250g",
"price": 85000,
"status": "active",
"created_at": "2024-03-15T08:30:00Z",
"updated_at": "2024-03-15T08:30:00Z"
}
}
// Success — collection dengan pagination
{
"status": "success",
"data": [ ... ],
"meta": {
"current_page": 1,
"per_page": 15,
"total": 247,
"last_page": 17
}
}
// Error — validasi (422)
{
"status": "error",
"message": "Data tidak valid.",
"errors": {
"name": ["Nama produk wajib diisi."],
"price": ["Harga harus berupa angka positif."]
}
}
// Error — not found (404)
{
"status": "error",
"message": "Produk tidak ditemukan."
}
// Error — server (500) — JANGAN bocorkan stack trace ke client
{
"status": "error",
"message": "Terjadi kesalahan server. Silakan coba lagi."
}
Rumus. Selalu kembalikan field
status("success"/"error") di setiap response. Fielddatauntuk payload sukses,errorsuntuk detail validasi,messageuntuk pesan yang aman ditampilkan ke pengguna akhir. Jangan pernah kembalikan stack trace PHP/Java/Python di response produksi.
Cheat Sheet Database Migration
Database migration adalah mekanisme version control untuk skema database Anda. Tanpa migration, perubahan skema di laptop developer tidak sampai ke staging atau production secara konsisten — hasilnya adalah bug yang sulit direpro, kolom yang hilang, atau data yang corrupt. Cheat sheet ini mencakup perintah migration untuk tiga tool paling umum: Laravel (PHP), Prisma (Node.js/TypeScript), dan Flyway (Java/agnostik).
Perbandingan Tool Migration
| Tool | Ekosistem | Format Migration | Rollback | Cocok Untuk |
|---|---|---|---|---|
| Laravel Artisan | PHP / Laravel | PHP class (up/down) | Ya (rollback) |
Proyek Laravel, UMKM, startup |
| Prisma Migrate | Node.js / TypeScript | SQL otomatis dari schema.prisma | Manual (SQL) | Next.js fullstack, TypeScript |
| Flyway | Java / agnostik | SQL atau Java class | Undo (berbayar) / manual | Enterprise, multi-bahasa |
| Alembic | Python / SQLAlchemy | Python (upgrade/downgrade) | Ya (downgrade) |
FastAPI, Django-agnostik |
| Knex.js | Node.js | JS (up/down) | Ya | Express.js, Fastify |
Laravel Artisan Migration — Perintah Lengkap
| Perintah | Fungsi |
|---|---|
php artisan make:migration create_products_table |
Buat file migration baru |
php artisan migrate |
Jalankan semua migration pending |
php artisan migrate:status |
Cek status semua migration |
php artisan migrate:rollback |
Rollback batch terakhir |
php artisan migrate:rollback --step=3 |
Rollback 3 migration terakhir |
php artisan migrate:fresh |
Drop semua tabel lalu migrate (dev only!) |
php artisan migrate:fresh --seed |
Fresh + jalankan seeder |
php artisan migrate --pretend |
Preview SQL yang akan dijalankan |
<?php
// database/migrations/2024_01_15_000001_create_products_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->ulid('id')->primary(); // ULID lebih baik dari UUID v4 (sortable)
$table->string('name', 255);
$table->text('description')->nullable();
$table->unsignedBigInteger('price'); // Rupiah, tidak pakai decimal
$table->unsignedBigInteger('category_id'); // NO FK — referensi manual
$table->enum('status', ['active', 'inactive', 'draft'])->default('draft');
$table->unsignedBigInteger('stock')->default(0);
// AUDIT TRAIL — wajib di setiap tabel
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps(); // created_at, updated_at
$table->softDeletes(); // deleted_at — WAJIB
// Index manual (karena NO FK)
$table->index('category_id');
$table->index('status');
$table->index('deleted_at'); // untuk query soft delete
$table->index(['status', 'deleted_at']); // composite untuk filter umum
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};
Prisma Migrate — Perintah Lengkap
| Perintah | Fungsi |
|---|---|
npx prisma migrate dev --name init |
Buat & jalankan migration baru (dev) |
npx prisma migrate deploy |
Jalankan migration di production |
npx prisma migrate status |
Cek status migration |
npx prisma migrate reset |
Reset DB + re-migrate (dev only!) |
npx prisma db push |
Push schema tanpa migration file (prototyping) |
npx prisma generate |
Generate Prisma Client setelah schema berubah |
npx prisma studio |
Buka GUI browser untuk browse data |
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Product {
id String @id @default(cuid())
name String @db.VarChar(255)
description String? @db.Text
price Int // Rupiah, integer
categoryId Int // NO FK — manual reference
status ProductStatus @default(DRAFT)
stock Int @default(0)
// Audit trail
createdBy Int?
updatedBy Int?
deletedBy Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // Soft delete
@@index([categoryId])
@@index([status])
@@index([deletedAt])
@@index([status, deletedAt])
@@map("products")
}
enum ProductStatus {
ACTIVE
INACTIVE
DRAFT
}
Flyway — Perintah & Konvensi Penamaan File
| Perintah | Fungsi |
|---|---|
flyway migrate |
Jalankan semua migration pending |
flyway info |
Lihat status semua migration |
flyway validate |
Validasi checksum migration |
flyway repair |
Perbaiki metadata setelah migration gagal |
flyway baseline |
Set baseline untuk DB yang sudah ada |
-- db/migration/V1__create_products_table.sql
-- Konvensi nama: V{versi}__{deskripsi}.sql (dua underscore)
CREATE TABLE products (
id VARCHAR(26) PRIMARY KEY, -- ULID
name VARCHAR(255) NOT NULL,
description TEXT,
price BIGINT NOT NULL CHECK (price >= 0), -- Rupiah
category_id BIGINT NOT NULL, -- NO FK
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('active','inactive','draft')),
stock BIGINT NOT NULL DEFAULT 0,
-- Audit trail
created_by BIGINT,
updated_by BIGINT,
deleted_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- NULL = aktif, ISI = soft deleted
);
-- Index manual (karena NO FK)
CREATE INDEX idx_products_category_id ON products (category_id);
CREATE INDEX idx_products_status ON products (status);
CREATE INDEX idx_products_deleted_at ON products (deleted_at);
CREATE INDEX idx_products_status_del ON products (status, deleted_at);
-- Trigger update updated_at otomatis
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_products_updated_at
BEFORE UPDATE ON products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Awas. Jangan pernah jalankan
migrate:freshatauflyway cleandi environment production. Perintah ini menghapus semua tabel. Tambahkan guard di pipeline CI/CD: blokir perintah destruktif kecuali environment-nya adalahlocalatautesting.
Aturan Emas Migration
| # | Aturan | Alasan |
|---|---|---|
| 1 | Migration harus idempoten jika memungkinkan | Aman dijalankan ulang jika gagal di tengah jalan |
| 2 | Satu migration = satu perubahan logis | Rollback lebih presisi, review lebih mudah |
| 3 | Jangan edit migration yang sudah di-commit ke main | Checksum berubah = Flyway/Prisma error di server lain |
| 4 | Selalu tulis method down() atau migration undo |
Rollback cepat saat production incident |
| 5 | Tambahkan index di migration, bukan setelah deploy | Lupa index = query lambat yang baru ketahuan di produksi |
| 6 | Kolom deleted_at wajib ada di semua tabel entitas |
Soft delete adalah rumus wajib — data tidak pernah benar-benar hilang |
| 7 | Kolom created_by, updated_by, deleted_by wajib ada |
Audit trail: siapa mengubah apa dan kapan |
Tips. Simpan semua file migration di repositori Git. Jangan pernah jalankan DDL (
ALTER TABLE,CREATE INDEX) langsung di database production lewat psql atau DBeaver tanpa membuat migration file terlebih dahulu. Database yang tidak sinkron dengan codebase adalah sumber bug yang paling mematikan dan paling sulit di-debug.
Daftar Tools & Layanan (Gratis & Berbayar)
Ekosistem alat pengembang tumbuh pesat. Daftar berikut dikurasi untuk tim kecil (1–5 orang) yang membangun MVP atau PoC dengan anggaran terbatas. Setiap kategori memuat pilihan gratis, freemium, dan berbayar — pilih berdasarkan kebutuhan nyata, bukan tren.
Desain & Prototyping
| Tool | Tier Gratis | Berbayar | Catatan |
|---|---|---|---|
| Figma | 3 proyek, 3 editor | $15/editor/bln | Standar industri UI/UX; plugin terbanyak |
| Penpot | Self-host gratis | Cloud $7/bln | Open-source, SVG-native, baik untuk UMKM |
| Canva | Terbatas | Rp 179 rb/bln | Marketing material, bukan UI; mudah digunakan non-desainer |
| Whimsical | 4 board gratis | $10/bln | Wireframe cepat, flowchart, mind map |
| Excalidraw | Gratis penuh | Plus $7/bln | Sketsa cepat, kolaborasi real-time, open-source |
| Miro | 3 board | $10/bln | Whiteboard kolaboratif; baik untuk brainstorming tim |
Code Editor & IDE
| Tool | Tier Gratis | Berbayar | Catatan |
|---|---|---|---|
| VS Code | Gratis penuh | — | Ekosistem extension terluas; default pilihan banyak tim |
| Cursor | 2000 req/bln AI | $20/bln | VS Code fork + AI inline; wajib coba untuk produktivitas |
| JetBrains (WebStorm/PHPStorm) | 30 hari trial | Rp 250 rb/bln | Refactor cerdas; cocok proyek enterprise besar |
| Neovim | Gratis penuh | — | Sangat ringan; butuh konfigurasi awal |
| Zed | Gratis penuh | — | Editor baru performa tinggi, multiplayer native |
| GitHub Codespaces | 60 jam/bln | $0.18/jam | Dev environment cloud, langsung dari browser |
Database & ORM
| Tool/Layanan | Tier Gratis | Berbayar | Catatan |
|---|---|---|---|
| Supabase (PostgreSQL) | 500 MB DB, 2 proyek | $25/bln | BaaS lengkap: auth, storage, realtime |
| PlanetScale (MySQL) | 5 GB, 1 DB | $39/bln | Branching schema ala Git; cocok tim agile |
| Neon (PostgreSQL serverless) | 0.5 GB, auto-suspend | $19/bln | Scale-to-zero; baik untuk PoC hemat biaya |
| MongoDB Atlas | 512 MB shared | $57/bln (M10) | Document DB; gratis cukup untuk MVP awal |
| Railway (PostgreSQL/MySQL/Redis) | $5 kredit/bln | Pay-as-you-go | Deploy DB + app sekaligus; DX sangat baik |
| Prisma ORM | Gratis (open-source) | Prisma Data Platform $29/bln | Type-safe ORM untuk Node.js/TypeScript |
| Drizzle ORM | Gratis (open-source) | — | Lightweight SQL-first ORM; zero runtime overhead |
| TablePlus / DBeaver | Gratis terbatas / Gratis penuh | $79 lifetime | GUI client DB; DBeaver open-source lintas platform |
Deploy & Hosting
| Platform | Tier Gratis | Berbayar | Cocok Untuk |
|---|---|---|---|
| Vercel | Personal gratis, 100 GB bandwidth | $20/bln (Pro) | Next.js, static, serverless functions |
| Netlify | 100 GB bandwidth, 300 build-mnt | $19/bln | Jamstack, form, edge functions |
| Railway | $5 kredit/bln | Pay-as-you-go | Full-stack: API + DB + cron satu platform |
| Render | 750 jam/bln web service | $7/bln (starter) | Docker, worker, cron; spin-down gratis 15 mnt |
| Fly.io | 3 VM shared gratis | Pay-as-you-go | Container global edge; Docker native |
| DigitalOcean App Platform | 3 static site gratis | $12/bln | Simple PaaS; integrasi DO Spaces & DB |
| Cloudflare Pages + Workers | Unlimited request | $5/bln | Edge computing; sangat murah untuk traffic tinggi |
| Niagahoster Cloud | — | Rp 15 rb–Rp 100 rb/bln | VPS lokal Indonesia; latensi rendah pengguna ID |
Monitoring & Observability
| Tool | Gratis | Berbayar | Fungsi Utama |
|---|---|---|---|
| Sentry | 5K error/bln | $26/bln | Error tracking & performance frontend/backend |
| BetterStack (Logtail) | 1 GB log/bln | $24/bln | Log management + uptime monitoring |
| Grafana + Prometheus | Self-host gratis | Grafana Cloud $0/mo (10K seri) | Metrics dashboard; infra monitoring |
| Datadog | 1 host gratis | $15/host/bln | APM lengkap; mahal tapi komprehensif |
| UptimeRobot | 50 monitor, 5 mnt interval | $7/bln | Uptime & SSL monitoring sederhana |
| Posthog | 1M event/bln | $0.00045/event | Product analytics, session recording, feature flags |
Autentikasi & Keamanan
| Tool | Gratis | Berbayar | Catatan |
|---|---|---|---|
| Clerk | 10K MAU | $25/bln | Auth siap pakai; UI komponen cantik |
| Auth0 | 7.5K MAU | $23/bln | Enterprise SSO; banyak social provider |
| Supabase Auth | 50K MAU | Bundled | JWT + Row Level Security PostgreSQL |
| NextAuth.js / Auth.js | Gratis open-source | — | Self-host; 40+ provider OAuth |
| Cloudflare Turnstile | Gratis unlimited | — | CAPTCHA alternatif ramah pengguna |
Payment Gateway (Indonesia)
| Provider | MDR Kartu | Transfer Bank | QRIS | Catatan |
|---|---|---|---|---|
| Midtrans (Gojek) | 2–3% | Rp 4.000–6.000 | 0.7% | Terpopuler Indonesia; sandbox gratis |
| Xendit | 2.5–3% | Rp 5.000 | 0.7% | API-first; disbursement mudah |
| DOKU | 2.5% | Rp 5.000 | 0.7% | BUMN-linked; cocok enterprise pemerintah |
| Stripe | 2.9% + $0.30 | — | — | Belum support IDR native; cocok SaaS global |
Tips. Untuk MVP Indonesia, Midtrans Sandbox adalah pilihan pertama: gratis, dokumentasi bahasa Indonesia, dan coverage channel terlengkap (VA, QRIS, GoPay, OVO, ShopeePay).
Kolaborasi & Manajemen Proyek
| Tool | Gratis | Berbayar | Catatan |
|---|---|---|---|
| Linear | 250 isu | $8/bln | Issue tracker tercepat; keyboard-first |
| Notion | Unlimited halaman personal | $10/bln | Docs + DB + wiki; semua dalam satu |
| GitHub Issues + Projects | Gratis publik & privat | $4/bln (Team) | Terintegrasi code; cukup untuk tim kecil |
| Slack | 90 hari histori | Rp 140 rb/bln | Komunikasi tim; integrasi GitHub/Linear |
| Discord | Gratis penuh | Nitro $9.99/bln | Komunitas + tim kecil; voice murah |
Daftar API AI untuk Integrasi
Integrasi AI ke dalam aplikasi MVP tidak selalu mahal. Pilih API berdasarkan fungsi yang dibutuhkan, bukan popularitas semata. Tabel berikut mencakup layanan terkurasi dengan estimasi harga per Agustus 2025 — selalu periksa halaman pricing resmi sebelum produksi.
Large Language Model (LLM) — Teks & Chat
| Provider / Model | Input (per 1M token) | Output (per 1M token) | Keunggulan | Catatan |
|---|---|---|---|---|
| OpenAI GPT-4o | $2.50 | $10.00 | Multimodal terbaik; vision + audio | API paling matang, dokumentasi lengkap |
| OpenAI GPT-4o mini | $0.15 | $0.60 | Cepat & murah; cocok chatbot sederhana | Pilihan utama MVP untuk efisiensi biaya |
| OpenAI o3-mini | $1.10 | $4.40 | Reasoning kuat untuk analisis kompleks | Chain-of-thought internal; lambat tapi akurat |
| Anthropic Claude Sonnet 4 | $3.00 | $15.00 | Coding & analisis dokumen panjang | Context window 200K token; sangat baik untuk RAG |
| Anthropic Claude Haiku 3.5 | $0.80 | $4.00 | Cepat & hemat; respons <1 detik | Pilihan terbaik untuk volume tinggi |
| Google Gemini 1.5 Pro | $1.25 | $5.00 | Context 2M token; multimodal native | Gratis tier lewat Google AI Studio |
| Google Gemini 2.0 Flash | $0.075 | $0.30 | Tercepat di kelas; cocok real-time | Experimental gratis via API key |
| Mistral Large 2 | $2.00 | $6.00 | Multilingual kuat; GDPR-compliant (EU) | Self-host tersedia via Mistral AI |
| Mistral 7B (self-host) | Biaya infra sendiri | — | Gratis model; kontrol penuh data | Butuh GPU A100/H100 untuk produksi |
| Groq (Llama 3.1 70B) | $0.59 | $0.79 | Inference tercepat di dunia (LPU) | Gratis 14.4K req/hari pada free tier |
| Together AI | $0.20–$0.90 | $0.20–$0.90 | Banyak model open-source; fine-tune mudah | Pilihan untuk eksperimen model beragam |
| Ollama (self-host) | Gratis | — | Local inference; privasi data penuh | Cocok PoC internal tanpa kirim data ke cloud |
// Contoh integrasi OpenAI GPT-4o mini (Node.js / TypeScript)
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function chatWithAI(userMessage: string): Promise<string> {
const response = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "Kamu asisten helpdesk untuk aplikasi UMKM." },
{ role: "user", content: userMessage },
],
max_tokens: 512,
temperature: 0.7,
});
return response.choices[0].message.content ?? "";
}
Text-to-Speech & Voice AI
| Provider | Harga | Keunggulan | Catatan |
|---|---|---|---|
| ElevenLabs | $0.30/1K karakter (Creator) | Kualitas suara paling natural | Gratis 10K karakter/bln; kloning suara tersedia |
| OpenAI TTS | $15/1M karakter | Integrasi mudah satu SDK | 6 suara bawaan; latensi rendah |
| Google Cloud TTS | $4/1M karakter (WaveNet) | Mendukung Bahasa Indonesia | 100 bahasa; Neural2 & Studio voices |
| Azure Cognitive Speech | $1/1M karakter (Neural) | SSML penuh; paling murah Neural | 5 jam gratis/bln; cocok enterprise |
| PlayHT | $0.30/1K karakter | Real-time streaming TTS | Ultra-low latency 75ms |
Speech-to-Text (Transkripsi)
| Provider | Harga | Akurasi Indonesia | Catatan |
|---|---|---|---|
| OpenAI Whisper API | $0.006/menit | Baik | Open-source juga tersedia self-host |
| AssemblyAI | $0.0058/menit | Sedang | Speaker diarization, sentiment, LeMUR |
| Google Speech-to-Text | $0.006/menit | Sangat Baik | Model Chirp 2; Bahasa Indonesia v2 |
| Deepgram Nova 2 | $0.0043/menit | Sedang | Tercepat; streaming real-time |
Image & Video AI
| Provider / Model | Harga | Fungsi | Catatan |
|---|---|---|---|
| Stability AI (SDXL) | $0.065/gambar | Text-to-image | API REST; self-host model juga bisa |
| OpenAI DALL-E 3 | $0.04–$0.12/gambar | Text-to-image | Integrasi GPT-4o; kualitas konsisten |
| Replicate | Pay-per-second GPU | Model marketplace | 100+ model; cocok eksperimen cepat |
| Cloudflare AI (Workers AI) | 10K neuron/hari gratis | Inference edge | Llama, Mistral, Stable Diffusion di edge |
| Fal.ai | $0.00013/megapixel | Fast image gen | LoRA fine-tune; streaming diffusion |
Rumus. Untuk MVP pertama: mulai dengan Groq (gratis & cepat) untuk prototipe, lalu migrasi ke Claude Haiku atau GPT-4o mini saat sudah ada pengguna nyata. Jangan over-engineer pilihan model sebelum ada data penggunaan riil.
# Contoh integrasi Anthropic Claude via Python (untuk backend FastAPI)
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY dari env
def analyze_document(text: str) -> str:
message = client.messages.create(
model="claude-haiku-3-5-20241022",
max_tokens=1024,
messages=[
{
"role": "user",
"content": f"Ringkas dokumen berikut dalam 3 poin:\n\n{text}"
}
]
)
return message.content[0].text
Perbandingan Harga Cloud Provider (Rp)
Memilih cloud provider bukan hanya soal harga sticker — faktor latensi ke pengguna Indonesia, kemudahan pembayaran dalam Rupiah, dan ketersediaan support lokal ikut menentukan. Tabel berikut menggunakan kurs estimasi USD 1 = Rp 16.000 (per pertengahan 2025).
Awas. Harga cloud berubah sewaktu-waktu. Selalu gunakan kalkulator resmi masing-masing provider (AWS Pricing Calculator, GCP Pricing Calculator, dst.) sebelum commit ke anggaran produksi.
VPS / Compute (1 vCPU, 2 GB RAM, 50 GB SSD)
| Provider | Harga/bln (USD) | Estimasi (Rp) | Region Terdekat | Catatan |
|---|---|---|---|---|
| AWS EC2 t3.small | ~$16.72 | ~Rp 268.000 | ap-southeast-1 (Singapura) | On-demand; Reserved 1yr hemat 40% |
| Google Cloud e2-small | ~$13.80 | ~Rp 221.000 | asia-southeast2 (Jakarta) | Jakarta region tersedia sejak 2021 |
| Azure B1ms | ~$17.52 | ~Rp 280.000 | Southeast Asia (Singapura) | Belum ada region Indonesia |
| DigitalOcean Basic | $12.00 | ~Rp 192.000 | SGP1 (Singapura) | Harga flat; billing mudah |
| Vultr Cloud Compute | $12.00 | ~Rp 192.000 | Singapore / Tokyo | Promo kredit $100 untuk baru |
| Biznet Gio (Indonesia) | ~Rp 150.000 | Rp 150.000 | Jakarta DC | Lokal; bayar Rupiah; latensi <10ms |
| Niagahoster VPS | — | Rp 75.000–300.000 | Jakarta | Lokal; support 24/7 bahasa Indonesia |
| IDCloudHost | — | Rp 99.000–350.000 | Jakarta & Surabaya | ISO 27001; cocok data sensitif lokal |
Object Storage (100 GB)
| Provider | Storage/bln | Bandwidth/GB | Estimasi (Rp/bln) | Catatan |
|---|---|---|---|---|
| AWS S3 | $0.023/GB = $2.30 | $0.09/GB | ~Rp 37.000 (storage saja) | Paling mature; egress mahal |
| Google Cloud Storage | $0.020/GB = $2.00 | $0.12/GB | ~Rp 32.000 | Nearline & Coldline lebih murah |
| Azure Blob Storage | $0.018/GB = $1.80 | $0.087/GB | ~Rp 29.000 | LRS/ZRS/GRS tier berbeda harga |
| Cloudflare R2 | $0.015/GB = $1.50 | Gratis egress | ~Rp 24.000 | Zero egress cost; revolusi object storage |
| Backblaze B2 | $0.006/GB = $0.60 | $0.01/GB | ~Rp 10.000 | Termurah; kompatibel S3 API |
| DigitalOcean Spaces | $5 flat (250 GB) | Gratis 1 TB | ~Rp 80.000 | CDN built-in; bisa buat 250 GB |
Rumus. Untuk MVP Indonesia: gunakan kombinasi Biznet Gio / Niagahoster VPS (compute lokal, latensi rendah) + Cloudflare R2 (storage murah, egress gratis) + Cloudflare CDN (gratis unlimited). Total biaya bisa di bawah Rp 250.000/bulan untuk MVP dengan 1.000 pengguna aktif.
Managed Database (PostgreSQL, 1 vCPU, 1 GB RAM)
| Provider | Harga/bln (USD) | Estimasi (Rp) | Backup | Catatan |
|---|---|---|---|---|
| AWS RDS t3.micro | ~$16.79 | ~Rp 269.000 | 7 hari | Multi-AZ +2x harga; production ready |
| Google Cloud SQL f1-micro | ~$7.67 | ~Rp 123.000 | 7 hari | Termurah managed DB cloud global |
| DigitalOcean Managed PG | $15.00 | ~Rp 240.000 | 7 hari | Failover otomatis; DX sangat baik |
| Supabase (Pro) | $25.00 | ~Rp 400.000 | 7 hari PITR | Auth + storage + realtime bundled |
| Neon Serverless | $19.00 | ~Rp 304.000 | Auto | Scale-to-zero; branching DB |
Bandwidth / CDN Egress (per 100 GB)
| Provider | Harga/GB | 100 GB (Rp) | Catatan |
|---|---|---|---|
| AWS CloudFront | $0.0085–$0.12 | ~Rp 14.000–192.000 | Tergantung region; Singapura $0.12 |
| Cloudflare CDN | Gratis (Pro $20) | Rp 0 | Unlimited bandwidth; wajib pakai |
| BunnyCDN | $0.01/GB | ~Rp 16.000 | Edge storage + CDN; sangat terjangkau |
| DigitalOcean | $0.01/GB (setelah 1 TB) | ~Rp 0 s/d 16.000 | 1 TB gratis/bln untuk setiap droplet |
Template Technical Specification Document
Technical Specification Document (TechSpec) adalah kontrak teknis antara product manager, desainer, dan engineer sebelum sprint dimulai. TechSpec yang baik mencegah asumsi tersembunyi yang menjadi bug mahal di production.
Template berikut tersedia dalam format Markdown dan siap disalin ke Notion, GitHub Wiki, atau file TECHSPEC.md di root repositori. Ganti semua teks dalam tanda [...] dengan nilai nyata proyek Anda.
# Technical Specification — [Nama Fitur / Modul]
**Dokumen ID**: SPEC-[NOMOR]
**Versi**: 1.0.0
**Tanggal**: [YYYY-MM-DD]
**Penulis**: [Nama]
**Reviewer**: [Nama Lead Engineer]
**Status**: Draft | Review | Approved | Deprecated
---
## 1. Ringkasan Eksekutif
[1–2 paragraf menjelaskan fitur, masalah yang dipecahkan, dan dampak bisnis.
Contoh: "Modul manajemen stok memungkinkan tim gudang mencatat keluar-masuk
barang secara real-time, mengurangi selisih stok dari 15% ke <1%."]
**Keputusan Utama**:
- Gunakan PostgreSQL (bukan MongoDB) karena relasi stok-varian-gudang terstruktur
- Soft delete wajib: tidak ada data stok yang dihapus permanen
- No foreign key constraint: integrasi di application layer
---
## 2. Latar Belakang & Konteks
### 2.1 Masalah Saat Ini
[Deskripsikan pain point konkret. Sertakan data/metrik jika ada.]
### 2.2 Solusi yang Diusulkan
[Pendekatan teknis tingkat tinggi. Hindari detail implementasi di sini.]
### 2.3 Out of Scope
- [Hal yang sengaja tidak dikerjakan di sprint ini]
- [Fitur yang akan dibangun di iterasi berikutnya]
---
## 3. Persyaratan Fungsional
### 3.1 User Stories
```
Sebagai [AKTOR], saya ingin [AKSI], sehingga [MANFAAT].
US-001: Sebagai staff gudang, saya ingin mencatat penerimaan barang
dengan scan barcode, sehingga input lebih cepat dan akurat.
US-002: Sebagai manajer, saya ingin melihat laporan stok real-time
per gudang, sehingga keputusan reorder lebih tepat.
```
### 3.2 Acceptance Criteria
- [ ] Staff dapat input penerimaan <30 detik per item
- [ ] Laporan stok update dalam <5 detik setelah transaksi
- [ ] Semua mutasi tercatat di audit trail (created_by, created_at)
- [ ] Soft delete: item terhapus masih bisa di-restore dalam 30 hari
---
## 4. Persyaratan Non-Fungsional
| Aspek | Target |
|-------|--------|
| Latency API | P95 < 200ms |
| Throughput | 100 req/detik pada peak |
| Uptime | 99.5% (monthly) |
| Data Retention | 5 tahun (audit trail), 30 hari soft-delete |
| Concurrent Users | 200 user aktif bersamaan |
---
## 5. Desain Sistem
### 5.1 Arsitektur Tinggi
```
[Browser / Mobile App]
|
[API Gateway / Nginx]
|
[Backend Service — Node.js / Laravel / Go]
| |
[PostgreSQL] [Redis Cache]
|
[Object Storage — Cloudflare R2]
```
### 5.2 Diagram Alur Data
[Sertakan diagram Mermaid atau link ke Figma/Excalidraw]
```mermaid
sequenceDiagram
Actor Staff
Staff->>API: POST /api/v1/inventory/receive
API->>DB: INSERT stock_transactions (soft-deleteable)
API->>Redis: INVALIDATE cache:stock:[warehouse_id]
API-->>Staff: 201 Created + transaction_id
```
---
## 6. Desain Database
### 6.1 Schema Utama
```sql
-- Tabel utama: tidak ada foreign key constraint (integritas di app layer)
CREATE TABLE stock_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_id UUID NOT NULL, -- no FK, validated in app
product_id UUID NOT NULL, -- no FK, validated in app
variant_id UUID,
type VARCHAR(20) NOT NULL, -- 'receive' | 'issue' | 'adjust'
quantity NUMERIC(12,3) NOT NULL,
unit VARCHAR(20) NOT NULL,
reference_no VARCHAR(100),
notes TEXT,
-- Audit trail wajib
created_by UUID NOT NULL,
updated_by UUID,
deleted_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- soft delete: NULL = aktif
);
-- Index manual menggantikan foreign key
CREATE INDEX idx_stock_tx_warehouse ON stock_transactions(warehouse_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_stock_tx_product ON stock_transactions(product_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_stock_tx_created ON stock_transactions(created_at DESC)
WHERE deleted_at IS NULL;
```
### 6.2 Aturan Integritas Aplikasi
- Validasi `warehouse_id` eksis sebelum insert (query `warehouses` tanpa JOIN)
- Validasi `product_id` eksis sebelum insert (query `products` tanpa JOIN)
- Semua query WAJIB include `WHERE deleted_at IS NULL` (global scope)
---
## 7. Desain API
### 7.1 Endpoint List
| Method | Path | Deskripsi | Auth |
|--------|------|-----------|------|
| GET | /api/v1/inventory | List transaksi stok (paginated) | Bearer |
| POST | /api/v1/inventory/receive | Input penerimaan barang | Bearer |
| POST | /api/v1/inventory/issue | Input pengeluaran barang | Bearer |
| GET | /api/v1/inventory/:id | Detail transaksi | Bearer |
| DELETE | /api/v1/inventory/:id | Soft delete transaksi | Bearer + role:manager |
| POST | /api/v1/inventory/:id/restore | Restore soft-deleted | Bearer + role:admin |
### 7.2 Request / Response Contoh
```json
// POST /api/v1/inventory/receive
// Request Body:
{
"warehouse_id": "uuid-gudang-jakarta",
"product_id": "uuid-produk-sabun",
"quantity": 500,
"unit": "pcs",
"reference_no": "PO-2025-001",
"notes": "Penerimaan dari supplier PT Maju"
}
// Response 201:
{
"success": true,
"data": {
"id": "uuid-transaksi-baru",
"type": "receive",
"quantity": 500,
"created_at": "2025-06-16T08:30:00Z",
"created_by": "uuid-user-staff"
}
}
```
---
## 8. Keamanan
- Semua endpoint memerlukan JWT Bearer token (RS256)
- RBAC: role `staff` hanya bisa create, `manager` bisa soft-delete, `admin` bisa force-delete & restore
- Rate limiting: 60 req/menit per user untuk write endpoint
- Input validation: sanitasi semua field string, max length, tipe data
- Log semua akses gagal ke tabel `security_logs`
---
## 9. Rencana Testing
| Level | Tool | Coverage Target |
|-------|------|----------------|
| Unit Test | Jest / PHPUnit / Go test | 80% baris kode |
| Integration Test | Supertest / Postman | Semua endpoint happy path |
| Load Test | k6 | 100 req/s selama 5 menit |
| Security Test | OWASP ZAP scan | OWASP Top 10 |
---
## 10. Rencana Rollout
- [ ] Week 1: Schema migration & seed data
- [ ] Week 2: Backend API + unit test
- [ ] Week 3: Frontend CRUD UI + integration test
- [ ] Week 4: UAT dengan 5 user internal
- [ ] Week 5: Load test + security scan
- [ ] Week 6: Deploy production (feature flag OFF)
- [ ] Week 7: Gradual rollout 10% → 50% → 100%
---
## 11. Risiko & Mitigasi
| Risiko | Probabilitas | Dampak | Mitigasi |
|--------|-------------|--------|----------|
| Data stok tidak sinkron saat concurrent update | Sedang | Tinggi | Optimistic locking + row-level lock saat adjust |
| Performa lambat query stok agregat | Tinggi | Sedang | Materialized view direfresh tiap 5 menit |
| Kehilangan data saat soft-delete massal | Rendah | Sangat Tinggi | Konfirmasi 2 langkah + email notif admin |
---
## 12. Glosarium Lokal
| Istilah | Definisi dalam konteks proyek ini |
|---------|----------------------------------|
| Penerimaan | Transaksi masuk barang dari supplier (type=receive) |
| Pengeluaran | Transaksi keluar barang ke produksi/customer (type=issue) |
| Penyesuaian | Koreksi stok karena selisih opname (type=adjust) |
| Gudang | Entitas fisik penyimpanan barang |
---
*Dokumen ini harus diperbarui setiap ada perubahan signifikan pada desain.
Versi lama disimpan di git history. Jangan hapus — arsip sejarah keputusan teknis.*
Glosarium
Glosarium ini mengumpulkan istilah teknis yang digunakan di seluruh buku. Definisi ditulis kontekstual — bukan kamus akademik, tapi penjelasan praktis yang langsung bisa diterapkan saat membangun produk.
| Istilah | Definisi |
|---|---|
| MVP (Minimum Viable Product) | Versi produk dengan fitur sesedikit mungkin yang tetap cukup untuk diuji ke pengguna nyata dan memvalidasi hipotesis bisnis. Bukan "produk jelek", tapi produk fokus. |
| PoC (Proof of Concept) | Demonstrasi teknis bahwa sebuah ide bisa diimplementasikan. Biasanya tidak untuk produksi — hanya bukti kelayakan teknis sebelum investasi penuh. |
| MD Document (Market Discovery) | Dokumen riset pasar awal yang memvalidasi masalah, segmen pengguna, kompetitor, dan channel distribusi sebelum kode pertama ditulis. |
| RAG (Retrieval-Augmented Generation) | Teknik AI yang menggabungkan pencarian dokumen relevan (retrieval) dengan generasi teks LLM. Hasilnya lebih akurat dan faktual karena berbasis data nyata, bukan hanya training data model. |
| ORM (Object-Relational Mapper) | Library yang memetakan tabel database ke objek dalam kode. Contoh: Prisma, Eloquent, SQLAlchemy. Memungkinkan query database tanpa menulis SQL mentah. |
| CRUD | Create, Read, Update, Delete — empat operasi dasar data. Hampir semua fitur aplikasi bisnis adalah varian dari CRUD. |
| Soft Delete | Menandai record sebagai "terhapus" dengan mengisi kolom deleted_at tanpa benar-benar menghapus baris dari database. Data bisa dipulihkan; audit trail tetap utuh. |
| Hard Delete | Menghapus baris dari database secara permanen dengan DELETE FROM. Tidak bisa di-undo tanpa backup. Sebaiknya hanya untuk data yang memang wajib dihapus (GDPR right-to-erasure). |
| Audit Trail | Rekam jejak setiap perubahan data: siapa yang membuat (created_by), mengubah (updated_by), atau menghapus (deleted_by), kapan, dan dari data apa ke data apa. Wajib untuk aplikasi bisnis dan compliance. |
| Idempotent | Operasi yang menghasilkan hasil sama meskipun dijalankan berkali-kali. Contoh: PUT /users/1 dengan data yang sama harus selalu menghasilkan state yang sama, tidak menambah duplikat. |
| JWT (JSON Web Token) | Token autentikasi berisi klaim terenkripsi (user ID, role, expiry). Tidak perlu query database per request karena klaim sudah ada di token. Gunakan RS256 untuk produksi. |
| RBAC (Role-Based Access Control) | Model kontrol akses berdasarkan peran pengguna (admin, manager, staff, viewer). Setiap peran memiliki set permission yang berbeda. Lebih sederhana dari ABAC untuk kebanyakan aplikasi bisnis. |
| CDN (Content Delivery Network) | Jaringan server tersebar secara geografis yang menyajikan aset statis (gambar, CSS, JS) dari lokasi terdekat ke pengguna. Mengurangi latensi dan beban server asal. |
| Load Balancer | Komponen yang mendistribusikan traffic ke beberapa server backend. Mencegah satu server kelebihan beban dan memungkinkan zero-downtime deployment. |
| Sharding | Teknik mempartisi database secara horizontal — data dibagi ke beberapa database berdasarkan kunci tertentu (user ID, region). Digunakan saat satu database tidak cukup menampung volume data. |
| N+1 Problem | Anti-pattern query: 1 query untuk daftar, lalu N query tambahan untuk setiap item (total N+1 query). Solusi: eager loading, JOIN, atau DataLoader untuk GraphQL. |
| Cursor Pagination | Paginasi menggunakan pointer/cursor ke posisi terakhir, bukan offset angka. Lebih efisien dan konsisten untuk dataset besar yang sering berubah. Contoh: ?cursor=eyJpZCI6MTAwfQ==&limit=20 |
| Modular Monolith | Arsitektur satu deployment unit (monolith) tapi kode diorganisir dalam modul-modul terpisah dengan batas yang jelas. Lebih mudah dikelola dari monolith tradisional, lebih murah dari microservices. |
| Microservices | Arsitektur di mana setiap fungsi bisnis berjalan sebagai layanan terpisah dengan database sendiri. Kompleks di operasional tapi memungkinkan scale dan deploy independen per layanan. |
| API Gateway | Titik masuk tunggal untuk semua request ke layanan backend. Mengelola routing, rate limiting, autentikasi, logging, dan transformasi request/response. |
| Webhook | HTTP callback yang dikirim otomatis dari sistem eksternal ke endpoint kita saat suatu event terjadi. Contoh: Midtrans mengirim notifikasi pembayaran ke /api/webhooks/payment. |
| Rate Limiting | Membatasi jumlah request yang bisa dilakukan klien dalam periode waktu tertentu. Melindungi API dari abuse dan DDoS. Implementasi: token bucket atau sliding window counter di Redis. |
| Queue / Message Queue | Antrian tugas yang diproses secara asinkron oleh worker terpisah. Cocok untuk email, notifikasi, laporan besar, atau integrasi pihak ketiga yang tidak harus selesai sebelum response dikirim. |
| Worker | Proses background yang mengkonsumsi tugas dari queue. Berjalan independen dari web server. Bisa di-scale horizontal tanpa mempengaruhi API layer. |
| Migration (Database) | File script SQL atau kode yang mendefinisikan perubahan schema database secara bertahap dan terversi. Memungkinkan rollback dan kolaborasi tim tanpa konflik schema. |
| Seed (Database) | Data awal yang dimasukkan ke database untuk keperluan development atau demo. Berbeda dari fixture testing — seed untuk lingkungan kerja, fixture untuk test. |
| Index (Database) | Struktur data tambahan yang mempercepat pencarian kolom tertentu. Tanpa index, database membaca seluruh tabel (full scan). Wajib dibuat untuk kolom yang sering difilter atau di-JOIN. |
| No Foreign Key | Pendekatan yang menghindari constraint FK di level database, memindahkan validasi integritas referensial ke application layer. Lebih fleksibel untuk distributed system dan soft delete, tapi butuh disiplin lebih. |
| Optimistic Locking | Teknik concurrency control: record dibaca dengan versi/timestamp, saat update sistem cek apakah versi masih sama. Jika berubah (ada update lain), operasi ditolak dan user diminta coba ulang. |
| Pessimistic Locking | Mengunci record saat dibaca sehingga tidak ada proses lain yang bisa mengubahnya sampai lock dilepas. Lebih aman untuk transaksi finansial tapi mengurangi throughput. |
| Caching | Menyimpan hasil komputasi atau query ke penyimpanan cepat (Redis, Memcached) agar request berikutnya tidak perlu menghitung ulang. Kunci: strategy invalidasi cache yang tepat. |
| TTL (Time-To-Live) | Durasi maksimum sebuah entri cache atau record valid sebelum dianggap kadaluarsa. Contoh: cache profil user dengan TTL 5 menit. |
| Idempotency Key | Key unik yang dikirim klien bersama request untuk mencegah duplikasi. Jika request dengan key yang sama masuk lagi, server mengembalikan response yang tersimpan tanpa memproses ulang. Penting untuk payment. |
| CORS (Cross-Origin Resource Sharing) | Mekanisme browser yang mengizinkan atau memblokir request dari domain berbeda. Backend harus mengkonfigurasi header Access-Control-Allow-Origin agar frontend bisa memanggil API. |
| SSR (Server-Side Rendering) | Halaman HTML di-render di server sebelum dikirim ke browser. Lebih baik untuk SEO dan First Contentful Paint. Next.js, Nuxt, SvelteKit mendukung SSR. |
| CSR (Client-Side Rendering) | Browser menerima HTML kosong lalu JavaScript me-render konten. Lebih interaktif tapi SEO lebih buruk. Cocok untuk aplikasi internal (dashboard) yang tidak butuh SEO. |
| SSG (Static Site Generation) | Halaman di-generate saat build time, bukan saat request. Sangat cepat dan bisa di-serve dari CDN tanpa server. Cocok untuk landing page dan dokumentasi. |
| ISR (Incremental Static Regeneration) | Fitur Next.js: halaman statis di-regenerate secara bertahap di background berdasarkan interval atau on-demand, tanpa full rebuild. Kombinasi SSG dan SSR. |
| WebSocket | Protokol koneksi dua arah persisten antara client dan server. Memungkinkan server mengirim data ke client tanpa client request terlebih dahulu. Digunakan untuk chat, notifikasi real-time, live dashboard. |
| SSE (Server-Sent Events) | Alternatif WebSocket untuk komunikasi satu arah (server ke client). Lebih sederhana, bekerja lewat HTTP biasa. Cocok untuk live feed, progress tracking. |
| GDPR / UU PDP | Regulasi perlindungan data pribadi. GDPR di Eropa, UU PDP di Indonesia. Implikasi teknis: right-to-erasure (hard delete atas permintaan), consent tracking, data minimization. |
| SLA (Service Level Agreement) | Perjanjian formal antara penyedia layanan dan pengguna tentang tingkat layanan yang dijamin (uptime, response time). Contoh: "99.9% uptime = maksimal 8.7 jam downtime per tahun". |
| P95 / P99 Latency | Latensi persentil ke-95/99 dari semua request. P95 < 200ms berarti 95% request selesai dalam 200ms. Lebih representatif dari rata-rata karena menangkap outlier. |
| Horizontal Scaling | Menambah lebih banyak server/instance untuk menangani beban lebih tinggi. Lebih fleksibel dan resilien dari vertical scaling (memperbesar satu server). |
| Vertical Scaling | Menambah resource (CPU, RAM) pada satu server yang sudah ada. Lebih mudah tapi ada batas maksimum hardware dan menyebabkan single point of failure. |
| CI/CD (Continuous Integration/Continuous Delivery) | Praktik otomasi: setiap push kode memicu test otomatis (CI), dan jika lulus, deploy otomatis ke staging atau produksi (CD). Mengurangi risiko human error dalam deployment. |
| Feature Flag | Toggle konfigurasi yang memungkinkan fitur diaktifkan/dinonaktifkan tanpa deploy ulang. Berguna untuk rollout bertahap, A/B testing, dan kill switch darurat. |
| Canary Deployment | Deploy fitur baru ke sebagian kecil pengguna (misalnya 5%) terlebih dahulu. Jika metrik normal, perluasan dilanjutkan. Jika ada masalah, rollback hanya mempengaruhi sebagian kecil pengguna. |
| Blue-Green Deployment | Mempertahankan dua environment identik (blue = produksi aktif, green = versi baru). Saat siap, traffic dialihkan sepenuhnya ke green. Rollback instan: kembalikan traffic ke blue. |
| DRY (Don't Repeat Yourself) | Prinsip desain: setiap pengetahuan atau logika harus memiliki satu representasi tunggal dalam sistem. Duplikasi kode = hutang teknis yang akan menyebabkan bug saat satu salinan diperbarui tapi lainnya tidak. |
| YAGNI (You Aren't Gonna Need It) | Jangan membangun fitur sampai benar-benar dibutuhkan. Antidote terhadap over-engineering dan gold-plating yang menghamburkan waktu tim MVP. |
| Technical Debt | Hutang akibat mengambil jalan pintas teknis. Seperti hutang finansial: bunga terakumulasi jika tidak dibayar. Harus dijadwal untuk dilunasi, bukan diabaikan selamanya. |
| Monorepo | Menyimpan beberapa proyek (frontend, backend, mobile, shared library) dalam satu repositori Git. Memudahkan berbagi kode dan atomic commit lintas proyek. Tools: Turborepo, Nx, Lerna. |
Inti. Glosarium bukan untuk dihafal — jadikan referensi saat menulis dokumentasi teknis atau saat onboarding anggota tim baru. Istilah yang konsisten dalam satu tim mengurangi miskomunikasi dan mempercepat code review.