Seri Rumus · Volume Aplikasi

RUMUS APLIKASI

Dari Ide MVP ke Produk Jadi — Panduan Lengkap Seluruh Layer: Ide, Arsitektur, UI/UX, Backend, Frontend, Database, Deployment, hingga Bisnis
Sainskerta · Seri Rumus — Engineering Playbook

RUMUS APLIKASI

Pembuka

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.

Pembuka

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

Ide & MVP Bagian 1 Arsitektur & DB (Bagian 2, 6) UI/UX (Bagian 3) Backend (Bagian 4) Frontend (Bagian 5) Deploy & Skala (Bagian 7, 9) Jenis App (Bagian 8) Bisnis & Monetisasi (Bagian 10)
Alur baca mengikuti urutan pembuatan aplikasi nyata: fondasi dulu, implementasi, lalu skala dan bisnis.

Penanda di dalam teks

PenandaArti
IntiSatu kalimat pokok yang wajib diingat
RumusPola/keputusan yang direkomendasikan hampir selalu
TipsCara praktis mempercepat hasil
AwasRisiko, jebakan, atau batas
ContohPenerapan 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.

01
Bagian Satu

Fondasi Ide & Brainstorming

Bab 1

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.

Bab 2

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.

Bab 3

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:

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.

MINGGU W1 W2 W3 W4 W5 W6 W7 W8 W9–W12 FASE 1 Setup & Design Repo · DB schema · Wireframe FASE 2 Core Build Auth · CRUD fitur inti · API · UI dasar FASE 3 Polish & Beta Bug fix · Beta 10 user FASE 4 Launch & Iterate Public launch · Feedback loop M1 M2 M3 M1 = Design freeze M2 = Feature complete M3 = Beta freeze → Launch
Timeline MVP 12 minggu — 4 fase dengan 3 milestone wajib. Sesuaikan durasi fase 2 berdasarkan kompleksitas fitur inti.

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.

Bab 4

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:

Brand voice adalah kepribadian produk dalam komunikasi. Definisikan brand voice dalam tiga dimensi:

  1. Tone spectrum: Formal ↔ Kasual. Produk B2B enterprise biasanya lebih formal; produk konsumen millennial/Gen-Z lebih kasual.
  2. Expertise level: Teknis ↔ Non-teknis. Bagaimana Anda berbicara tentang produk Anda kepada audiens awam?
  3. 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:

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.

02
Bagian Dua

Arsitektur & Infrastruktur

Bab 5

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

DimensiModular MonolithMicroservicesServerlessEvent-Driven
Kompleksitas awalRendahSangat tinggiRendah–SedangSedang
Kecepatan iterasi MVPSangat cepatLambatCepatSedang
Biaya infra (traffic rendah)Tetap (VPS)Tinggi (N server)Sangat rendahSedang
DebuggingMudahSulit (distributed)SedangSulit (async)
Scaling per-komponenTidakYaOtomatisYa (consumer)
Isolasi kegagalanRendahTinggiTinggiSedang
Ukuran tim ideal2–10>201–155–30
Cocok untuk MVPYa (pilihan utama)TidakTergantungSebagian
Modular Monolith Modul Auth Modul Order Modul User Modul Product 1 Database Auth Service Order Service User Service Microservices fn: getUser fn: createOrder fn: sendEmail Serverless Event Bus / Broker Producer Consumer Event-Driven
Empat pola arsitektur. Modular Monolith (kiri) ideal untuk MVP: satu proses, modul terisolasi, satu database.

Bab berikutnya membahas Modular Monolith secara mendalam — termasuk struktur folder, batas modul, dan kapan saatnya migrasi ke microservices.

Bab 6

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:

SinyalIndikator KonkretSolusi Sebelum Microservices
Deploy terlalu sering terganggu>30 developer push ke branch yang sama setiap hariTrunk-based development + feature flag
Bottleneck performa satu modulModul laporan mengonsumsi 80% CPU saat generate PDFCaching, async queue, read replica
Kebutuhan bahasa berbedaML inference perlu Python, tapi app utama Node.jsSidecar process atau gRPC service kecil
Regulasi isolasi dataData kesehatan harus di server terpisah per hukumMicroservice untuk domain spesifik itu saja
Tim terpisah secara geografisTim A di Jakarta, Tim B di Surabaya — koordinasi sulitMicroservice 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.

Bab 7

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:

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:

SkenarioJenis IndexContoh
Filter kolom tunggal yang sering dipakaiB-tree standardidx_orders_user_id
Query hanya pada data aktif (soft delete)Partial indexWHERE deleted_at IS NULL
Search teks bebasGIN + tsvectoridx_products_fts
Filter multi-kolom berurutanComposite index(status, created_at DESC)
Data JSONB (PostgreSQL)GIN indexidx_meta_gin ON orders USING GIN(meta)
Kolom UUID yang banyak di-JOINB-tree standardSudah otomatis di PK, buat di FK manual

Tips. Jalankan EXPLAIN ANALYZE pada 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';
Bab 8

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

FiturPostgreSQLMySQL 8MariaDBSQLiteMongoDB
Tipe data kayaYa (JSONB, ARRAY, UUID, INET)Terbatas (JSON tanpa index GIN)Mirip MySQLMinimalYa (BSON)
Full-text searchBuilt-in (tsvector)Built-in (terbatas)Built-inDasarAtlas Search / Lucene
ACID compliancePenuhPenuh (InnoDB)PenuhPenuhMulti-doc transaksi (v4+)
Window functionsYaYa (v8+)YaYa (v3.25+)Tidak
ReplicationStreaming (logical/physical)YaGalera clusterTidakReplica set
Managed cloudRDS, Supabase, Neon, RailwayRDS, PlanetScaleRDSTurso, embeddedAtlas
LisensiPostgreSQL (open)GPL/CommercialGPL (open)Public domainSSPL (partial open)
Cocok untuk MVPSangat direkomendasikanBaikBaikPrototipe/offline appKonten 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:

KondisiSQL (Relasional)NoSQL
Data terstruktur, skema konsistenYaBisa, tapi berlebihan
Relasi kompleks antar-entitasYa (JOIN efisien)Sulit (embedding atau denormalisasi)
Transaksi ACID multi-tabelYa, nativeTerbatas/kompleks
Skema berubah-ubah antar-recordTidak idealYa (document DB)
Tulis sangat tinggi (>100k/s)Butuh tuningYa (Cassandra, DynamoDB)
Graph relationship (social network)Bisa, tapi lambatYa (Neo4j, ArangoDB)
Cache + sessionTidak idealYa (Redis)
Search full-text kompleksTerbatasYa (Elasticsearch)

Rekomendasi per Jenis Aplikasi

Jenis AplikasiDatabase PrimerDatabase Sekunder
SaaS B2B (ERP, CRM, HRIS)PostgreSQLRedis (cache + queue)
E-commercePostgreSQLRedis + Elasticsearch
Aplikasi media/konten (blog, CMS)PostgreSQLRedis
Aplikasi IoT (telemetri)TimescaleDB (PostgreSQL extension)Redis
Chat/messaging real-timePostgreSQL + MongoDBRedis Pub/Sub
Mobile app offline-firstSQLite (lokal) + PostgreSQL (server)
Analytics/reporting besarPostgreSQL + ClickHouse
Prototipe/MVP personalSQLite

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:

  1. EXPLAIN ANALYZE: Jalankan selalu sebelum anggap query sudah optimal. Perhatikan Seq Scan (tanda index tidak dipakai) dan actual rows yang jauh berbeda dari estimasi.
  2. Partial index: Tambahkan WHERE deleted_at IS NULL pada semua index tabel ber-soft-delete. Index menjadi lebih kecil dan query lebih cepat.
  3. 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.
  4. Pagination dengan cursor: Hindari OFFSET besar. Gunakan cursor-based pagination dengan WHERE id > :lastId ORDER BY id LIMIT 20.
  5. 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.
Bab 9

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 CaseUkuran per Item10.000 itemTTL Wajar
Session user (JWT claims + meta)~2 KB~20 MB7 hari
Cache query list produk~50 KB (JSON 100 item)500 MB5–60 menit
Cache detail produk~3 KB~30 MB30 menit
Rate limit counter~100 B~1 MBSesuai window
BullMQ job pending~500 B~5 MBSampai diproses
OTP / verification code~200 B~2 MB5–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 memory dan aktifkan maxmemory-policy allkeys-lru agar Redis otomatis membuang data yang paling jarang diakses ketika memori penuh.

Bab 10

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:

API Versioning

Versioning memastikan perubahan breaking di API tidak merusak client yang sudah berjalan. Ada tiga strategi umum:

StrategiContohProKontra
URL prefix/api/v1/productsMudah di-routing, eksplisitURL berubah, tidak REST-pure
Header AcceptAccept: application/vnd.api.v1+jsonURL bersihSulit di-debug, perlu dokumentasi
Query param/products?version=1Mudah di-testTidak 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:

EndpointLimitWindowAlasan
POST /auth/login10 req15 menitCegah brute force password
POST /auth/otp/request3 req10 menitCegah spam SMS/email
GET /products (public)200 req1 menitCegah scraping berlebihan
GET /products (authenticated)1000 req1 menitLimit wajar untuk aplikasi aktif
POST /orders30 req1 menitCegah order spam
POST /files/upload20 req1 jamCegah penyalahgunaan storage
Bab 11

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:

AspekJWT (Stateless)Session + Redis (Stateful)
Penyimpanan stateDi client (cookie atau localStorage)Di server (Redis)
Revoke sebelum expireSulit (perlu token blacklist di Redis)Mudah (hapus session dari Redis)
Skalabilitas horizontalMudah (tidak perlu sticky session)Mudah jika Redis shared
Payload dataBisa menyimpan claims (role, userId)Hanya ID session di client
Keamanan logoutToken di client masih valid sampai expireLogout 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 localStorage untuk JWT — gunakan httpOnly cookie agar 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.

03
Bagian Tiga

UI/UX, Wireframing & Mockup

Bab 12

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.

USER JOURNEY MAP — Tambah Produk Baru Fase Tindakan Emosi Pain Point 1. Buka Aplikasi Login dengan akun toko 😐 Lupa password 2. Menu Produk Klik sidebar Produk 🙂 Ikon tidak jelas 3. Klik Tambah Form/modal terbuka 😅 Form terlalu panjang 4. Isi & Simpan Input nama, harga, stok 😊 5. Konfirmasi Produk muncul di daftar 😄
Gambar 12.1 — User Journey Map alur Tambah Produk Baru. Titik emosi negatif di langkah 3 (form terlalu panjang) menunjukkan peluang perbaikan: gunakan modal dengan field minimal, field tambahan bisa diisi belakangan.

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.

Bab 13

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.

app.contoh.com/produk MyApp Budi ▾ MENU UTAMA 📦 Produk 📊 Dashboard 🛒 Transaksi 📋 Laporan ⚙️ Pengaturan Dashboard › Produk Daftar Produk + Tambah 🔍 Cari produk... Nama Produk Harga Stok Aksi Beras Premium 5kg Rp 65.000 142 Detail Edit Hapus Minyak Goreng 1L Rp 18.500 3 ⚠ Detail Edit Hapus Gula Pasir 1kg Rp 14.000 89 Detail Edit Hapus Menampilkan 1–20 dari 247 produk 1
Gambar 13.1 — Wireframe lo-fi layout Sidebar Kiri + Konten Utama. Sidebar menampilkan navigasi dengan active state (Produk), konten utama berisi tabel dengan tombol aksi per baris dan tombol Tambah di kanan atas.

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:

Tools Wireframe: Gratis dan Berbayar

ToolTipeHargaKelebihanKekuranganCocok untuk
FigmaBrowser/DesktopGratis (starter) / $15/blnKolaborasi real-time, ekosistem plugin, prototypingLambat di koneksi lemahTim, startup, agency
ExcalidrawBrowser/Self-hostGratis sepenuhnyaCepat, hand-drawn feel, open sourceTidak ada prototypingLo-fi cepat, sesi ideasi
PenpotBrowser/Self-hostGratis (open source)Open source, bisa self-host, SVG nativeEkosistem lebih kecil dari FigmaTim yang ingin privasi data
WhimsicalBrowserGratis terbatas / $10/blnFlowchart + wireframe terintegrasiKurang fitur hi-fiAlur kerja + wireframe lo-fi
BalsamiqDesktop/Browser$9/blnHand-drawn style, fokus lo-fiTidak ada prototyping interaktifPresentasi konsep ke klien non-teknis
Adobe XDDesktopGratis terbatas / $9.99/blnIntegrasi Creative CloudAdobe kurangi investasi di XDTim yang sudah di ekosistem Adobe
Kertas + PenaFisikGratisTercepat, zero overheadTidak bisa di-share digital langsungIdeasi 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.

Bab 14

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).

M MyApp v2.1.0 MENU UTAMA 📦 Produk 12 📊 Dashboard 🛒 Transaksi 📋 Laporan › Stok › Penjualan › Export PENGATURAN ⚙️ Pengaturan 👤 Akun Saya M 📦 📊 🛒 📋 ⚙️ 👤 📱 Mobile View 📦 Produk 📊 Dash 🛒 Transaksi 📋 Laporan Lainnya Expanded Collapsed Mobile Bottom Nav
Gambar 14.1 — Tiga state sidebar kiri: expanded (ikon+teks), collapsed (ikon saja), dan mobile bottom navigation. Active state ditandai background biru + garis kuning di kiri. Badge notifikasi muncul di item yang relevan.

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 title attribute + 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.

Bab 15

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.

Sidebar Tambah Produk Baru Isi informasi produk di bawah ini Nama Produk * contoh: Beras Premium 5kg Kategori * Pilih kategori ▾ Satuan * kg / pcs / liter ▾ Harga Jual * Rp 0 Stok Awal 0 Deskripsi Deskripsi singkat produk (opsional) Batal Simpan Produk Simpan & Baru
Gambar 15.1 — Modal form Tambah Produk geser dari kanan. Overlay semi-transparan menutupi tabel di belakang (pengguna masih bisa melihat konteks). Footer modal berisi tiga aksi: Batal, Simpan, dan Simpan & Baru (untuk input berurutan cepat).

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.

Bab 16

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.

Sidebar Produk Beras Premium 5kg ID: PRD-00142 · Dibuat 10 Jun 2025 ✏ Edit 🗑 Hapus 🖨 Cetak Info Stok Riwayat Terkait NAMA PRODUK Beras Premium 5kg KATEGORI Sembako SATUAN kg HARGA JUAL Rp 65.000 HARGA BELI Rp 58.000 STOK SAAT INI 142 kg STOK MINIMAL 20 kg STATUS Aktif AKTIVITAS TERAKHIR Budi mengubah harga dari Rp 62.000 → Rp 65.000 10 Jun 2025, 14:23 Restok +50 kg oleh Admin
Gambar 16.1 — Slide panel Detail view. Header biru dengan nama record dan tombol aksi (Edit, Hapus, Cetak) selalu terlihat. Tab navigasi memisahkan Info, Stok, Riwayat, dan Data Terkait. Activity log di bawah menampilkan perubahan terbaru.

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.

Bab 17

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.

TokenNilaiPenggunaan
--brand#1e3a8aHeader, sidebar, tombol utama, link aktif
--brand-dark#0c1b3dSidebar background, teks di atas brand
--accent#f59e0bBadge, highlight, CTA sekunder
--tint#eef2fbRow hover, active state ringan, background tabel header
--line#d6def5Border tabel, divider, outline input
--success#10b981Status aktif, stok aman, konfirmasi berhasil
--danger#ef4444Hapus, stok kritis, error message
--warning#f59e0bPeringatan, stok mendekati minimal
--neutral-50#f8fafcBackground halaman
--neutral-700#374151Teks 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.

Bab 18

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.

FrameworkBundle Size (gzip)Learning CurveEkosistemPopularitas di IndonesiaGaji Developer (Jkt, 2025)Terbaik untuk
React + Next.js~45KB React coreMenengahSangat besarSangat tinggiRp 8–30 jt/blnSaaS, startup, enterprise, aplikasi data-heavy
Vue + Nuxt~34KB Vue coreRendah-MenengahBesarTinggiRp 6–22 jt/blnUMKM, dinas, tim kecil, iterasi cepat
Angular~75KB+ (full)TinggiBesar (Google)MenengahRp 12–40 jt/blnEnterprise besar, bank, telco, instansi pemerintah
Svelte + SvelteKit~10KB compiledRendahKecil-MenengahRendahRp 7–20 jt/blnPerforma kritis, produk baru, tim yang mau belajar
Solid.js~7KBRendah-MenengahKecilSangat rendahSulit rekrutEksperimen, proyek sampingan
Alpine.js~14KBSangat rendahKecilMenengahRp 4–12 jt/blnServer-rendered app (Laravel/Django), interaktivitas ringan
jQuery~30KBSangat rendahBesar (legacy)MenurunRp 4–10 jt/blnMaintenance 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.

Bab 19

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 StylingUkuran BundleKustomisasiTypeScript DXServer ComponentRekomendasi
Tailwind CSSSangat kecil (JIT)Sangat tinggiPlugin IDE tersediaPenuhUtama — semua proyek baru
shadcn/ui + TailwindKecilSangat tinggi (copy kode)Sangat baikPenuhKomponen siap + Tailwind
Bootstrap 5~22KB gzipMenengah (SCSS variables)BaikPenuhPrototype cepat / internal tools
Material UI (MUI)~100KB+ tree-shakenMenengah (theme)Sangat baikTerbatas (beta)Enterprise tanpa desainer
CSS ModulesSangat kecilPenuh (CSS manual)MenengahPenuhProyek Next.js tanpa Tailwind
Styled-componentsRuntime overheadSangat tinggiSangat baikTidak (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.

04
Bagian Empat

Backend & API Lengkap

Bab 20

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.

Bab 21

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" }. Field code adalah 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.

Bab 22

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.

Bab 23

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.

Bab 24

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.

1. Request Pengguna klik “Export PDF” 2. Queue Job Masukkan ke Redis Queue 3. Worker Generate PDF / Excel 4. Upload Simpan ke S3 / Storage 5. Notifikasi → Pengguna download via signed URL (15 menit)
Alur async report generation — request langsung kembali ke pengguna, proses berjalan di background

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.

Bab 25

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
Email 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 .env masuk 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.

05
Bagian Lima

Frontend Implementasi

Bab 26

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.

Bab 27

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.

Gambar 27.1 — Anatomi layout admin: sidebar kiri (biru gelap) dengan menu multi-level, header dengan breadcrumb dan kontrol pengguna, area konten dengan KPI cards dan tabel data.

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
Bab 28

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:

  1. DataTable — menampilkan list data dengan sorting, filter, search, dan pagination
  2. FormModal — panel geser dari kanan untuk form Tambah/Edit
  3. DetailPanel — slide panel yang lebih lebar untuk tampilan detail lengkap
  4. 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.

Bab 29

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 } → simpan file_id ke field form → saat form di-submit, kirim hanya file_id, bukan data file. Server backend yang menghubungkan ke file via file_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.

Bab 30

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-pdf yang 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
Bab 31

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
Bab 32

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.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.

06
Bagian Enam

Database Implementasi

Bab 33

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.

Bab 34

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 NULL di setiap query yang ditulis secara manual (raw SQL). Global scope hanya bekerja jika Anda menggunakan metode ORM — bukan raw query string. Periksa setiap prisma.$queryRaw atau DB::statement() yang ada di kode Anda.

Bab 35

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.

Bab 36

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 KEY constraint 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.

Bab 37

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

EXPLAIN ANALYZE Identifikasi query lambat Partial & Composite Index Kurangi rows scanned DataLoader / Eager Load Hilangkan N+1 query Cursor Pagination Konsisten di skala besar Redis Cache Query berat jarang berubah Select Kolom Spesifik Kurangi data transfer Urutan Prioritas Optimasi Query
Alur prioritas optimasi: mulai dari identifikasi bottleneck dengan EXPLAIN, lalu perbaiki dengan index, kemudian eliminasi N+1, dan terakhir tambahkan caching jika masih diperlukan.
-- 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.

07
Bagian Tujuh

Deployment & DevOps

Bab 38

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+. Pastikan ssl_dhparam di-generate dengan openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 dan 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 user deployer dengan PM2 yang dikonfigurasi via pm2 startup agar berjalan sebagai systemd service milik user tersebut.

Bab 39

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 alpine untuk 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.yml yang masuk ke version control. Gunakan file .env (masuk ke .gitignore) atau Docker Secrets untuk produksi. Minimal, pastikan .env selalu ada di .gitignore sebelum git init.

Bab 40

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 app yang akan mengganti container app tanpa menyentuh database atau Redis. Downtime praktis nol karena Docker menunggu health check baru sebelum menghentikan yang lama.

Bab 41

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.

Bab 42

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.

Bab 43

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.

Pengguna Jakarta Pengguna Surabaya CDN Cloudflare Edge Nodes Load Balancer App Server A App Server B PostgreSQL Primary + Replica Redis Cache & Queue static assets API requests
Arsitektur scaled: Pengguna → CDN (static) / Load Balancer (API) → App Servers → Redis Cache → PostgreSQL Primary+Replica.

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.

08
Bagian Delapan

7 Layer OSI & Jenis Aplikasi

Bab 44

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 7 — Application HTTP · HTTPS · DNS · SMTP · MQTT · REST API · GraphQL 7 Layer 6 — Presentation SSL/TLS · Enkripsi · JSON · XML · Protobuf · Gzip · Brotli 6 Layer 5 — Session WebSocket · Session Management · NetBIOS · RPC 5 Layer 4 — Transport TCP · UDP · Port Numbers · Flow Control · Retransmission 4 Layer 3 — Network IP Address · Router · Firewall · ICMP · BGP · NAT · CIDR 3 Layer 2 — Data Link Ethernet · Wi-Fi · MAC Address · Switch · Frame · ARP 2 Layer 1 — Physical Kabel · Fiber · Wi-Fi RF · GPIO · Sinyal Listrik · Bit 1 Data
Diagram tumpukan 7 Layer OSI — dari aplikasi (atas) turun ke sinyal fisik (bawah). Setiap layer hanya berkomunikasi dengan layer di atas dan bawahnya.
LayerNamaProtokol/TeknologiRelevansi DeveloperContoh Masalah
7ApplicationHTTP, DNS, SMTP, MQTTSangat tinggi — semua API ada di sini400 Bad Request, CORS error
6PresentationTLS, JSON, GzipTinggi — enkripsi & serialisasiSSL handshake failure, parse error
5SessionWebSocket, RPCSedang — koneksi real-timeWebSocket disconnect tanpa pesan
4TransportTCP, UDP, PortSedang — port & firewallConnection timeout, port blocked
3NetworkIP, Router, FirewallSedang — routing & VPNUnreachable host, routing loop
2Data LinkEthernet, Wi-Fi, MACRendah (kecuali IoT/LAN)Duplikat MAC, broadcast storm
1PhysicalKabel, Fiber, GPIORendah (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.

Bab 45

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">&times;</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.

PlatformAPI StandardStoreReview TimeRevenue ShareCatatan
ChromeMV3 / chrome.*Chrome Web Store2–7 hariGratis listTerbesar, 65%+ pengguna desktop
FirefoxWebExtensions / browser.*AMO (addons.mozilla.org)1–3 hariGratis listPrivacy-conscious users
SafariWebExtensions + macOS AppApp Store (macOS)5–14 hari30% (jika berbayar)Butuh Apple Developer account
EdgeMV3 (sama Chrome)Edge Add-ons Store3–7 hariGratis listGunakan 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.

Bab 46

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.

FrameworkBahasaPlatformUkuran BinaryKelebihanKekurangan
ElectronJS/TS + HTMLWin/Mac/Linux~150 MB+Ekosistem npm besar, mudahBerat RAM, Chromium bundled
TauriJS/TS + RustWin/Mac/Linux/Mobile<10 MBRingan, aman, fastPerlu Rust untuk native code
Flutter DesktopDartWin/Mac/Linux/Mobile~30 MBShare kode mobileDart ecosystem lebih kecil
.NET MAUIC#Win/Mac/Mobile~20 MBIntegrasi Microsoft stackmacOS/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.

Bab 47

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.

FrameworkBahasaPlatformPerformaUkuran TimTerbaik Untuk
React Native (Expo)TypeScriptAndroid + iOSBaikSolo / kecilCRUD app, startup MVP cepat
FlutterDartAndroid/iOS/Web/DesktopSangat baikSolo / kecilUI kompleks, semua platform
Kotlin MultiplatformKotlin + SwiftAndroid + iOSNativeSedangShare logic, UI native
Native AndroidKotlinAndroid sajaTerbaikSedangAkses hardware penuh
Native iOSSwiftiOS sajaTerbaikSedangApp 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.

Bab 48

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).

Bab 49

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-interactive atau --yes yang skip semua prompt konfirmasi. Exit code harus konsisten: 0 sukses, 1 error umum, 2 invalid usage. JSON output via flag --json untuk integrasi dengan tool lain.

Bab 50

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.

Bab 51

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 AITeknologi KunciModel/APIContoh ProdukMonetisasi
Chatbot BisnisLLM + RAG + Tool UseGPT-4o / ClaudeCS otomatis, FAQ botPer percakapan / per pesan
Generator KontenLLM + Prompt EngineeringGPT-4o-miniCaption, artikel, iklanSubscription kredit
Analyzer DokumenVision + LLMGPT-4o / GeminiOCR KTP, scan invoicePer dokumen
RecommenderEmbedding + Similaritytext-embedding-3Rekomendasi produk, kontenPer API call
Voice AISTT + LLM + TTSWhisper + GPT + TTSAsisten suara, interview AIPer menit audio
Deteksi AnomaliML klasik / LLMsklearn / GPTFraud detection, QC produkPer 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.

09
Bagian Sembilan

Skala, Pengujian & Optimasi

Bab 52

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) htop atau CloudWatch di server, (3) pg_stat_activity atau slow query log di database. Bottleneck hampir selalu terlihat jelas di salah satu dari ketiganya.

Bab 53

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 localStorage karena bisa diakses oleh XSS. Gunakan httpOnly cookie 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.

Bab 54

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.

Bab 55

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.

Application Server (PgBouncer Pool) Query Router Write→Primary / Read→Replica Primary READ + WRITE WAL streaming → Replica 1 READ ONLY lag: <100ms Replica 2 READ ONLY (Reporting) INSERT/UPDATE/DELETE SELECT
Arsitektur read replica: Primary menerima semua write dan streaming WAL ke replica. Query router mengarahkan SELECT ke replica, INSERT/UPDATE/DELETE ke primary.

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 tambahkan shared_preload_libraries = 'pg_stat_statements' di postgresql.conf. Ini adalah alat diagnosis performa database yang paling berharga — lebih actionable daripada slow query log karena sudah ter-aggregasi.

10
Bagian Sepuluh

Bisnis & Monetisasi

Bab 56

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)
Bab 57

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.

Bab 58

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

AWARENESS INTEREST TRIAL / DEMO BAYAR SEO · Sosmed · Ads · Word of mouth Landing page · Blog · Demo video Free trial · Onboarding · Support Upgrade flow · Billing · Retention 100% ~20–30% ~10–15% ~2–5%
Funnel akuisisi SaaS — angka konversi tipikal untuk produk B2B Indonesia. Dari 1.000 pengunjung landing page, sekitar 20–50 akan menjadi pelanggan berbayar dalam 30 hari pertama.

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.

Bab 59

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:

-- 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.

Bab 60

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:

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.

XI
Lampiran

Lampiran & Referensi

Lampiran A

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.md dalam 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.

Lampiran B

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 SegmentsValue PropositionsChannelsCustomer RelationshipsRevenue Streams, lalu kembali ke kiri: Key ResourcesKey ActivitiesKey PartnershipsCost Structure.

BUSINESS MODEL CANVAS Nama Produk: _________________ | Tanggal: _________________ | Versi: _____ KEY PARTNERSHIPS Mitra Kunci • Payment gateway (Midtrans) • Cloud provider (AWS/GCP) • Vendor SMS/WhatsApp [ tambahkan mitra Anda ] KEY ACTIVITIES Aktivitas Kunci • Pengembangan produk • Customer support • Marketing & sales [ tambahkan aktivitas ] KEY RESOURCES Sumber Daya Kunci • Tim teknis (developer) • Codebase & IP • Brand & reputasi [ tambahkan resource ] VALUE PROPOSITIONS Proposisi Nilai • Apa yang kami tawarkan? • Masalah apa yang dipecahkan? • Kebutuhan apa yang dipenuhi? Contoh: • Hemat 3 jam/hari admin • Laporan real-time • Harga terjangkau vs kompetitor [ nilai unik Anda di sini ] CUSTOMER RELATIONSHIPS • Self-service (dokumentasi) • Live chat / WhatsApp support • Onboarding concierge [ cara Anda merawat pelanggan ] CHANNELS Saluran • Website & SEO • Komunitas & referral • Direct sales [ saluran distribusi Anda ] CUSTOMER SEGMENTS Segmen Pelanggan Siapa pelanggan utama? • UMKM kuliner (primer) • Retail toko fisik (sekunder) Karakteristik: • Lokasi: kota tier-2 • Omzet <Rp 50jt/bulan • 1–5 karyawan [ segmen Anda di sini ] COST STRUCTURE — Struktur Biaya Biaya terbesar apa yang paling penting untuk model bisnis ini? • Biaya tetap: Gaji tim (developer, CS) — Rp ___ /bulan • Biaya variabel: Server/cloud — Rp ___ /bulan; Payment gateway fee — ___% per transaksi • Marketing: Rp ___ /bulan • Legal & compliance: Rp ___ /tahun [ rinci biaya operasional Anda ] REVENUE STREAMS — Aliran Pendapatan Bagaimana Anda menghasilkan uang dari setiap segmen pelanggan? • Subscription Starter: Rp 149.000/bulan (maks 2 pengguna, fitur dasar) • Subscription Pro: Rp 399.000/bulan (unlimited pengguna, semua fitur) • Setup fee onboarding: Rp 500.000 (sekali bayar) • Custom report add-on: Rp 100.000/laporan [ stream pendapatan Anda di sini ] Biru = Infrastruktur | Kuning = Nilai | Hijau = Pelanggan | Merah = Biaya | Kuning gelap = Pendapatan
Gambar L-B.1 — Business Model Canvas 9-blok. Salin, cetak A3, isi dengan sticky notes atau langsung di Figma.

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.

Lampiran C

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.

ANALISIS SWOT Produk: _________________ | Tanggal: _________________ | Tim: _________________ INTERNAL POSITIF INTERNAL NEGATIF S — STRENGTHS Kekuatan Internal Apa yang kami lakukan lebih baik dari siapa pun? • Tim teknis berpengalaman domain spesifik ini • Akses langsung ke 50+ calon pelanggan via komunitas • Biaya development lebih rendah (tim in-house) • Pemahaman lokal yang dalam vs kompetitor asing [ isi kekuatan nyata Anda — jujur dan spesifik ] [ hindari hal generik seperti "tim kami passionate" ] W — WEAKNESSES Kelemahan Internal Apa yang perlu diperbaiki? Apa yang dihindari pelanggan? • Belum punya track record / referensi pelanggan • Tidak ada tim sales dedicated • Modal terbatas, runway <6 bulan • Produk belum battle-tested di skala produksi [ jujur — investor tahu kelemahan Anda ] [ kelemahan yang disembunyikan = risiko tersembunyi ] EKSTERNAL POSITIF EKSTERNAL NEGATIF O — OPPORTUNITIES Peluang Eksternal Tren, perubahan pasar, atau celah yang bisa dimanfaatkan? • Pertumbuhan digitalisasi UMKM pasca pandemi • Program subsidi digitalisasi pemerintah (PaDi UMKM) • Kompetitor incumbent tidak update sejak 3 tahun lalu • Penetrasi 4G/5G meningkat ke kota tier-2 dan tier-3 [ peluang nyata, bukan angan-angan ] [ sertakan sumber data jika ada ] T — THREATS Ancaman Eksternal Apa yang bisa menggagalkan bisnis ini dari luar? • Tokopedia / Gojek merilis fitur serupa gratis • Regulasi baru terkait data lokal (PDPK) • Kenaikan biaya cloud AWS/GCP tiba-tiba • Resesi ekonomi → pelanggan stop bayar subscription [ ancaman realistis, bukan skenario apokalips ] [ untuk setiap ancaman, siapkan mitigasi ]
Gambar L-C.1 — Template SWOT 4 kuadran dengan contoh isian konteks UMKM Indonesia. Cetak A3 atau gunakan Miro/FigJam untuk sesi tim.

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.

Lampiran D

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. Field data untuk payload sukses, errors untuk detail validasi, message untuk pesan yang aman ditampilkan ke pengguna akhir. Jangan pernah kembalikan stack trace PHP/Java/Python di response produksi.

Lampiran E

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:fresh atau flyway clean di environment production. Perintah ini menghapus semua tabel. Tambahkan guard di pipeline CI/CD: blokir perintah destruktif kecuali environment-nya adalah local atau testing.

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.

Lampiran F

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

ToolTier GratisBerbayarCatatan
Figma3 proyek, 3 editor$15/editor/blnStandar industri UI/UX; plugin terbanyak
PenpotSelf-host gratisCloud $7/blnOpen-source, SVG-native, baik untuk UMKM
CanvaTerbatasRp 179 rb/blnMarketing material, bukan UI; mudah digunakan non-desainer
Whimsical4 board gratis$10/blnWireframe cepat, flowchart, mind map
ExcalidrawGratis penuhPlus $7/blnSketsa cepat, kolaborasi real-time, open-source
Miro3 board$10/blnWhiteboard kolaboratif; baik untuk brainstorming tim

Code Editor & IDE

ToolTier GratisBerbayarCatatan
VS CodeGratis penuhEkosistem extension terluas; default pilihan banyak tim
Cursor2000 req/bln AI$20/blnVS Code fork + AI inline; wajib coba untuk produktivitas
JetBrains (WebStorm/PHPStorm)30 hari trialRp 250 rb/blnRefactor cerdas; cocok proyek enterprise besar
NeovimGratis penuhSangat ringan; butuh konfigurasi awal
ZedGratis penuhEditor baru performa tinggi, multiplayer native
GitHub Codespaces60 jam/bln$0.18/jamDev environment cloud, langsung dari browser

Database & ORM

Tool/LayananTier GratisBerbayarCatatan
Supabase (PostgreSQL)500 MB DB, 2 proyek$25/blnBaaS lengkap: auth, storage, realtime
PlanetScale (MySQL)5 GB, 1 DB$39/blnBranching schema ala Git; cocok tim agile
Neon (PostgreSQL serverless)0.5 GB, auto-suspend$19/blnScale-to-zero; baik untuk PoC hemat biaya
MongoDB Atlas512 MB shared$57/bln (M10)Document DB; gratis cukup untuk MVP awal
Railway (PostgreSQL/MySQL/Redis)$5 kredit/blnPay-as-you-goDeploy DB + app sekaligus; DX sangat baik
Prisma ORMGratis (open-source)Prisma Data Platform $29/blnType-safe ORM untuk Node.js/TypeScript
Drizzle ORMGratis (open-source)Lightweight SQL-first ORM; zero runtime overhead
TablePlus / DBeaverGratis terbatas / Gratis penuh$79 lifetimeGUI client DB; DBeaver open-source lintas platform

Deploy & Hosting

PlatformTier GratisBerbayarCocok Untuk
VercelPersonal gratis, 100 GB bandwidth$20/bln (Pro)Next.js, static, serverless functions
Netlify100 GB bandwidth, 300 build-mnt$19/blnJamstack, form, edge functions
Railway$5 kredit/blnPay-as-you-goFull-stack: API + DB + cron satu platform
Render750 jam/bln web service$7/bln (starter)Docker, worker, cron; spin-down gratis 15 mnt
Fly.io3 VM shared gratisPay-as-you-goContainer global edge; Docker native
DigitalOcean App Platform3 static site gratis$12/blnSimple PaaS; integrasi DO Spaces & DB
Cloudflare Pages + WorkersUnlimited request$5/blnEdge computing; sangat murah untuk traffic tinggi
Niagahoster CloudRp 15 rb–Rp 100 rb/blnVPS lokal Indonesia; latensi rendah pengguna ID

Monitoring & Observability

ToolGratisBerbayarFungsi Utama
Sentry5K error/bln$26/blnError tracking & performance frontend/backend
BetterStack (Logtail)1 GB log/bln$24/blnLog management + uptime monitoring
Grafana + PrometheusSelf-host gratisGrafana Cloud $0/mo (10K seri)Metrics dashboard; infra monitoring
Datadog1 host gratis$15/host/blnAPM lengkap; mahal tapi komprehensif
UptimeRobot50 monitor, 5 mnt interval$7/blnUptime & SSL monitoring sederhana
Posthog1M event/bln$0.00045/eventProduct analytics, session recording, feature flags

Autentikasi & Keamanan

ToolGratisBerbayarCatatan
Clerk10K MAU$25/blnAuth siap pakai; UI komponen cantik
Auth07.5K MAU$23/blnEnterprise SSO; banyak social provider
Supabase Auth50K MAUBundledJWT + Row Level Security PostgreSQL
NextAuth.js / Auth.jsGratis open-sourceSelf-host; 40+ provider OAuth
Cloudflare TurnstileGratis unlimitedCAPTCHA alternatif ramah pengguna

Payment Gateway (Indonesia)

ProviderMDR KartuTransfer BankQRISCatatan
Midtrans (Gojek)2–3%Rp 4.000–6.0000.7%Terpopuler Indonesia; sandbox gratis
Xendit2.5–3%Rp 5.0000.7%API-first; disbursement mudah
DOKU2.5%Rp 5.0000.7%BUMN-linked; cocok enterprise pemerintah
Stripe2.9% + $0.30Belum 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

ToolGratisBerbayarCatatan
Linear250 isu$8/blnIssue tracker tercepat; keyboard-first
NotionUnlimited halaman personal$10/blnDocs + DB + wiki; semua dalam satu
GitHub Issues + ProjectsGratis publik & privat$4/bln (Team)Terintegrasi code; cukup untuk tim kecil
Slack90 hari historiRp 140 rb/blnKomunikasi tim; integrasi GitHub/Linear
DiscordGratis penuhNitro $9.99/blnKomunitas + tim kecil; voice murah
Lampiran G

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 / ModelInput (per 1M token)Output (per 1M token)KeunggulanCatatan
OpenAI GPT-4o$2.50$10.00Multimodal terbaik; vision + audioAPI paling matang, dokumentasi lengkap
OpenAI GPT-4o mini$0.15$0.60Cepat & murah; cocok chatbot sederhanaPilihan utama MVP untuk efisiensi biaya
OpenAI o3-mini$1.10$4.40Reasoning kuat untuk analisis kompleksChain-of-thought internal; lambat tapi akurat
Anthropic Claude Sonnet 4$3.00$15.00Coding & analisis dokumen panjangContext window 200K token; sangat baik untuk RAG
Anthropic Claude Haiku 3.5$0.80$4.00Cepat & hemat; respons <1 detikPilihan terbaik untuk volume tinggi
Google Gemini 1.5 Pro$1.25$5.00Context 2M token; multimodal nativeGratis tier lewat Google AI Studio
Google Gemini 2.0 Flash$0.075$0.30Tercepat di kelas; cocok real-timeExperimental gratis via API key
Mistral Large 2$2.00$6.00Multilingual kuat; GDPR-compliant (EU)Self-host tersedia via Mistral AI
Mistral 7B (self-host)Biaya infra sendiriGratis model; kontrol penuh dataButuh GPU A100/H100 untuk produksi
Groq (Llama 3.1 70B)$0.59$0.79Inference tercepat di dunia (LPU)Gratis 14.4K req/hari pada free tier
Together AI$0.20–$0.90$0.20–$0.90Banyak model open-source; fine-tune mudahPilihan untuk eksperimen model beragam
Ollama (self-host)GratisLocal inference; privasi data penuhCocok 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

ProviderHargaKeunggulanCatatan
ElevenLabs$0.30/1K karakter (Creator)Kualitas suara paling naturalGratis 10K karakter/bln; kloning suara tersedia
OpenAI TTS$15/1M karakterIntegrasi mudah satu SDK6 suara bawaan; latensi rendah
Google Cloud TTS$4/1M karakter (WaveNet)Mendukung Bahasa Indonesia100 bahasa; Neural2 & Studio voices
Azure Cognitive Speech$1/1M karakter (Neural)SSML penuh; paling murah Neural5 jam gratis/bln; cocok enterprise
PlayHT$0.30/1K karakterReal-time streaming TTSUltra-low latency 75ms

Speech-to-Text (Transkripsi)

ProviderHargaAkurasi IndonesiaCatatan
OpenAI Whisper API$0.006/menitBaikOpen-source juga tersedia self-host
AssemblyAI$0.0058/menitSedangSpeaker diarization, sentiment, LeMUR
Google Speech-to-Text$0.006/menitSangat BaikModel Chirp 2; Bahasa Indonesia v2
Deepgram Nova 2$0.0043/menitSedangTercepat; streaming real-time

Image & Video AI

Provider / ModelHargaFungsiCatatan
Stability AI (SDXL)$0.065/gambarText-to-imageAPI REST; self-host model juga bisa
OpenAI DALL-E 3$0.04–$0.12/gambarText-to-imageIntegrasi GPT-4o; kualitas konsisten
ReplicatePay-per-second GPUModel marketplace100+ model; cocok eksperimen cepat
Cloudflare AI (Workers AI)10K neuron/hari gratisInference edgeLlama, Mistral, Stable Diffusion di edge
Fal.ai$0.00013/megapixelFast image genLoRA 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
Lampiran H

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)

ProviderHarga/bln (USD)Estimasi (Rp)Region TerdekatCatatan
AWS EC2 t3.small~$16.72~Rp 268.000ap-southeast-1 (Singapura)On-demand; Reserved 1yr hemat 40%
Google Cloud e2-small~$13.80~Rp 221.000asia-southeast2 (Jakarta)Jakarta region tersedia sejak 2021
Azure B1ms~$17.52~Rp 280.000Southeast Asia (Singapura)Belum ada region Indonesia
DigitalOcean Basic$12.00~Rp 192.000SGP1 (Singapura)Harga flat; billing mudah
Vultr Cloud Compute$12.00~Rp 192.000Singapore / TokyoPromo kredit $100 untuk baru
Biznet Gio (Indonesia)~Rp 150.000Rp 150.000Jakarta DCLokal; bayar Rupiah; latensi <10ms
Niagahoster VPSRp 75.000–300.000JakartaLokal; support 24/7 bahasa Indonesia
IDCloudHostRp 99.000–350.000Jakarta & SurabayaISO 27001; cocok data sensitif lokal

Object Storage (100 GB)

ProviderStorage/blnBandwidth/GBEstimasi (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.000Nearline & Coldline lebih murah
Azure Blob Storage$0.018/GB = $1.80$0.087/GB~Rp 29.000LRS/ZRS/GRS tier berbeda harga
Cloudflare R2$0.015/GB = $1.50Gratis egress~Rp 24.000Zero egress cost; revolusi object storage
Backblaze B2$0.006/GB = $0.60$0.01/GB~Rp 10.000Termurah; kompatibel S3 API
DigitalOcean Spaces$5 flat (250 GB)Gratis 1 TB~Rp 80.000CDN 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)

ProviderHarga/bln (USD)Estimasi (Rp)BackupCatatan
AWS RDS t3.micro~$16.79~Rp 269.0007 hariMulti-AZ +2x harga; production ready
Google Cloud SQL f1-micro~$7.67~Rp 123.0007 hariTermurah managed DB cloud global
DigitalOcean Managed PG$15.00~Rp 240.0007 hariFailover otomatis; DX sangat baik
Supabase (Pro)$25.00~Rp 400.0007 hari PITRAuth + storage + realtime bundled
Neon Serverless$19.00~Rp 304.000AutoScale-to-zero; branching DB

Bandwidth / CDN Egress (per 100 GB)

ProviderHarga/GB100 GB (Rp)Catatan
AWS CloudFront$0.0085–$0.12~Rp 14.000–192.000Tergantung region; Singapura $0.12
Cloudflare CDNGratis (Pro $20)Rp 0Unlimited bandwidth; wajib pakai
BunnyCDN$0.01/GB~Rp 16.000Edge storage + CDN; sangat terjangkau
DigitalOcean$0.01/GB (setelah 1 TB)~Rp 0 s/d 16.0001 TB gratis/bln untuk setiap droplet
Lampiran I

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.*
Lampiran J

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.

IstilahDefinisi
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.
CRUDCreate, Read, Update, Delete — empat operasi dasar data. Hampir semua fitur aplikasi bisnis adalah varian dari CRUD.
Soft DeleteMenandai record sebagai "terhapus" dengan mengisi kolom deleted_at tanpa benar-benar menghapus baris dari database. Data bisa dipulihkan; audit trail tetap utuh.
Hard DeleteMenghapus 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 TrailRekam 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.
IdempotentOperasi 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 BalancerKomponen yang mendistribusikan traffic ke beberapa server backend. Mencegah satu server kelebihan beban dan memungkinkan zero-downtime deployment.
ShardingTeknik 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 ProblemAnti-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 PaginationPaginasi 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 MonolithArsitektur 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.
MicroservicesArsitektur di mana setiap fungsi bisnis berjalan sebagai layanan terpisah dengan database sendiri. Kompleks di operasional tapi memungkinkan scale dan deploy independen per layanan.
API GatewayTitik masuk tunggal untuk semua request ke layanan backend. Mengelola routing, rate limiting, autentikasi, logging, dan transformasi request/response.
WebhookHTTP callback yang dikirim otomatis dari sistem eksternal ke endpoint kita saat suatu event terjadi. Contoh: Midtrans mengirim notifikasi pembayaran ke /api/webhooks/payment.
Rate LimitingMembatasi 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 QueueAntrian 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.
WorkerProses 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 KeyPendekatan 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 LockingTeknik 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 LockingMengunci record saat dibaca sehingga tidak ada proses lain yang bisa mengubahnya sampai lock dilepas. Lebih aman untuk transaksi finansial tapi mengurangi throughput.
CachingMenyimpan 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 KeyKey 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.
WebSocketProtokol 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 PDPRegulasi 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 LatencyLatensi persentil ke-95/99 dari semua request. P95 < 200ms berarti 95% request selesai dalam 200ms. Lebih representatif dari rata-rata karena menangkap outlier.
Horizontal ScalingMenambah lebih banyak server/instance untuk menangani beban lebih tinggi. Lebih fleksibel dan resilien dari vertical scaling (memperbesar satu server).
Vertical ScalingMenambah 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 FlagToggle konfigurasi yang memungkinkan fitur diaktifkan/dinonaktifkan tanpa deploy ulang. Berguna untuk rollout bertahap, A/B testing, dan kill switch darurat.
Canary DeploymentDeploy 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 DeploymentMempertahankan 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 DebtHutang akibat mengambil jalan pintas teknis. Seperti hutang finansial: bunga terakumulasi jika tidak dibayar. Harus dijadwal untuk dilunasi, bukan diabaikan selamanya.
MonorepoMenyimpan 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.

Tentang Penulis
Galih Prasetyo

Seorang software engineer dan product builder yang menghabiskan lebih dari satu dekade membangun aplikasi — dari MVP startup yang lahir di kamar kos hingga sistem enterprise yang melayani jutaan pengguna. Ia percaya bahwa membangun produk yang baik bukan soal mengejar teknologi terbaru, melainkan soal disiplin pada fondasi: arsitektur yang jelas, database yang aman, dan UI yang dipahami pengguna.

Buku ini adalah rangkuman pola dan keputusan yang ia pakai berulang kali di lapangan — “rumus” yang ia harap bisa memangkas kurva belajar pembaca dari tahunan menjadi bulanan. Ditulis untuk siapa saja yang ingin mengubah ide menjadi produk nyata yang selesai, dipakai, dan bisa dirawat.

↑ Ke Atas