Arsitektur Read-Heavy API untuk Product Page Skala Tinggi
Di hampir semua platform e-commerce dan content catalog, product detail page adalah endpoint dengan read QPS tertinggi — jauh melebihi endpoint manapun. Masalahnya selalu sama: database mulai lambat, latency naik saat traffic spike, dan setiap “optimasi kecil” terasa tidak cukup. Akar masalahnya bukan di query yang kurang optimal, tapi di desain arsitektur yang memperlakukan read path seperti write path. Artikel ini membahas pendekatan pragmatis dan production-proven untuk membangun read-heavy API yang ringan, scalable, dan tahan tekanan — mulai dari prinsip dasar, strategi cache berlapis, composable endpoint, hingga kapan arsitektur ini belum perlu diterapkan.
Gambaran Besar — Arsitektur yang Direkomendasikan
Pendekatan yang benar memisahkan dua jalur secara tegas: read path yang ringan dan ter-cache, dan side-effect path yang async dan tidak memblokir response.
graph LR
Client["Client<br/>(Browser / Mobile)"]
BFF["BFF<br/>(Backend for Frontend)"]
subgraph "Read Path (Hot)"
Detail["GET /product/id/detail<br/>TTL: 10-30 menit"]
Price["GET /product/id/price<br/>TTL: 5-10 detik"]
Reviews["GET /product/id/reviews<br/>TTL: 30-120 detik"]
Reco["GET /product/id/recommendations<br/>TTL: 5-15 detik"]
end
subgraph "CDN Layer"
CDN["CDN / Edge Cache"]
end
subgraph "Cache Layer"
Redis["Redis / Memcached"]
end
subgraph "Side-Effect Path (Async)"
Queue["Message Queue<br/>(SQS / Kafka)"]
Worker["Async Worker<br/>(View count, analytics, last-viewed)"]
end
DB["Database<br/>(Only on cache miss)"]
Client --> BFF
BFF --> Detail & Price & Reviews & Reco
Detail & Price & Reviews & Reco --> Redis
Redis -->|"Cache miss"| DB
Client --> CDN
Detail -.->|"Fire & forget"| Queue
Price -.->|"Fire & forget"| Queue
Reviews -.->|"Fire & forget"| Queue
Reco -.->|"Fire & forget"| Queue
Queue --> Worker
style Client fill:#f5f5f4,stroke:#78716c,color:#000
style BFF fill:#e0e7ff,stroke:#4f46e5,color:#000
style Redis fill:#bfdbfe,stroke:#2563eb,color:#000
style CDN fill:#bfdbfe,stroke:#2563eb,color:#000
style Queue fill:#fef3c7,stroke:#d97706,color:#000
style Worker fill:#fef3c7,stroke:#d97706,color:#000
style DB fill:#bbf7d0,stroke:#16a34a,color:#000Target realistis yang bisa dicapai dengan pendekatan ini: 80–95% traffic berhenti di cache, database hanya menerima traffic dari cache miss.
Prinsip Utama — Pisahkan Read Murni dari Side Effect
Read API Harus Deterministik
Read API yang sehat punya satu karakteristik utama: request yang sama selalu menghasilkan response yang sama. Ini yang membuat read path bisa di-cache agresif dan diskalakan horizontal tanpa batas.
Masalah terjadi ketika read API mulai melakukan hal-hal yang mengubah state:
graph LR
subgraph "❌ Anti-Pattern: Side Effect di Read Path"
R1["GET /product/id"] --> DB1["Database"]
DB1 -->|"SELECT product"| R1
DB1 -->|"UPDATE view_count"| R1
DB1 -->|"INSERT analytics_event"| R1
DB1 -->|"UPDATE user.last_viewed"| R1
end
style R1 fill:#fecaca,stroke:#dc2626,color:#000
style DB1 fill:#fecaca,stroke:#dc2626,color:#000graph LR
subgraph "✅ Benar: Read Path Murni + Async Side Effects"
R2["GET /product/id"] --> Cache["Redis"]
Cache -->|"Cache miss only"| DB2["Database"]
R2 -.->|"Fire & forget"| Queue["Message Queue"]
Queue --> Worker["Async Worker"]
end
style R2 fill:#bbf7d0,stroke:#16a34a,color:#000
style Cache fill:#bfdbfe,stroke:#2563eb,color:#000
style DB2 fill:#bbf7d0,stroke:#16a34a,color:#000
style Queue fill:#fef3c7,stroke:#d97706,color:#000
style Worker fill:#fef3c7,stroke:#d97706,color:#000Side effect yang wajib dikeluarkan dari read path: view counter, last viewed user, analytics event, recommendation signal. Semuanya bisa dikirim sebagai fire-and-forget event ke message broker — tidak perlu menunggu hasilnya.
Cache Bukan Optimasi — Cache Adalah Desain
Pada sistem read-heavy, cache bukan lapisan tambahan yang ditempel setelah sistem berjalan. Cache adalah bagian dari arsitektur itu sendiri, dirancang dari awal bersamaan dengan API contract dan data model.
Cache yang dirancang terlambat hampir selalu punya masalah: cache key tidak konsisten, TTL sembarangan, strategi invalidasi tidak jelas, dan akhirnya justru menambah kompleksitas tanpa manfaat nyata.
Tiga Layer Cache
graph TB
Client["Client Request"]
CDN["Layer 1: CDN / Edge Cache<br/>Response stabil, dekat user"]
AppCache["Layer 2: Application Cache<br/>Redis / Memcached<br/>Per domain data"]
DB["Layer 3: Database<br/>Hanya saat cache miss"]
Client --> CDN
CDN -->|"Cache miss"| AppCache
AppCache -->|"Cache miss"| DB
style Client fill:#f5f5f4,stroke:#78716c,color:#000
style CDN fill:#bfdbfe,stroke:#2563eb,color:#000
style AppCache fill:#bfdbfe,stroke:#2563eb,color:#000
style DB fill:#bbf7d0,stroke:#16a34a,color:#000| Layer | Teknologi | Cocok Untuk | Target Hit Rate |
|---|---|---|---|
| CDN / Edge | CloudFront, Fastly | Response JSON/HTML stabil | 60–80% |
| Application Cache | Redis, Memcached | Hasil query per domain | 90–95% dari miss CDN |
| Database | PostgreSQL, MySQL | Source of truth | Hanya cache miss |
Aturan Cache Key
Cache key yang baik harus deterministik dan cukup spesifik. Terlalu broad menyebabkan stale data, terlalu narrow menyebabkan hit rate rendah.
product:detail:{product_id}
product:price:{product_id}:{variant_id}
product:reviews:latest:{product_id}
product:recommendation:{product_id}:{user_segment}
Masalah Endpoint Monolitik — TTL Terendah Menentukan Segalanya
Ini adalah hidden cost yang paling sering diabaikan. Jika satu endpoint menggabungkan semua data product page, TTL cache-nya harus mengikuti data yang paling cepat berubah. Artinya, product detail yang jarang berubah ikut di-invalidate setiap kali stok berubah.
| Komponen | Karakteristik | Frekuensi Berubah | TTL Ideal |
|---|---|---|---|
| Product detail | Stabil (nama, deskripsi, gambar) | Jarang (jam–hari) | 10–30 menit |
| Harga / Stok | Sensitif, sering berubah | Sering (detik–menit) | 5–10 detik |
| Latest reviews | Append-only | Sedang (menit) | 30–120 detik |
| Rekomendasi | Sangat dinamis per user | Sangat sering | 5–15 detik |
Jika semua dipaksa dalam satu endpoint dengan satu TTL → TTL ditarik ke yang paling sensitif (5–10 detik) → cache churn sangat tinggi → database kena beban hampir sama seperti tanpa cache.
Composable Read APIs — Satu Domain, Satu Endpoint
Solusinya adalah memecah satu endpoint gemuk menjadi beberapa endpoint berdasarkan domain data. Ini adalah penerapan ringan dari CQRS (Command Query Responsibility Segregation) di sisi read.
graph LR
subgraph "❌ Anti-Pattern: Monolithic Endpoint"
BFF1["BFF"] --> Fat["GET /product/id<br/>detail + price + reviews<br/>+ recommendations + view tracking"]
Fat --> DB1["DB (semua query)"]
end
style Fat fill:#fecaca,stroke:#dc2626,color:#000
style DB1 fill:#fecaca,stroke:#dc2626,color:#000graph LR
subgraph "✅ Benar: Composable Endpoints"
BFF2["BFF"] --> E1["GET /product/id/detail<br/>TTL: 10-30 menit"]
BFF2 --> E2["GET /product/id/price<br/>TTL: 5-10 detik"]
BFF2 --> E3["GET /product/id/reviews<br/>TTL: 30-120 detik"]
BFF2 --> E4["GET /product/id/recommendations<br/>TTL: 5-15 detik"]
end
style BFF2 fill:#e0e7ff,stroke:#4f46e5,color:#000
style E1 fill:#bbf7d0,stroke:#16a34a,color:#000
style E2 fill:#bbf7d0,stroke:#16a34a,color:#000
style E3 fill:#bbf7d0,stroke:#16a34a,color:#000
style E4 fill:#bbf7d0,stroke:#16a34a,color:#000Setiap endpoint bisa memiliki: cache sendiri dengan TTL sesuai karakteristik data, storage berbeda (Redis-only, read replica, search index), dan strategi invalidasi yang independen. Kegagalan satu domain tidak merusak seluruh halaman.
Kapan Perlu Split Endpoint?
| Kondisi | Rekomendasi |
|---|---|
| Data punya TTL ideal yang sangat berbeda | Split endpoint |
| Sebagian data berubah jauh lebih sering | Split endpoint |
| Kegagalan satu data tidak boleh merusak halaman | Split endpoint |
| Cache churn tinggi dan DB load meningkat | Split endpoint |
| Traffic masih rendah, data belum stabil | Tunda split, satu endpoint dulu |
| Tim kecil, fokus validasi produk | Tunda split, simpel lebih baik |
Rule praktis: jika lebih dari dua kondisi di atas terpenuhi, split endpoint hampir selalu keputusan yang tepat.
BFF — Jangan Lempar Kompleksitas ke Client
Memecah endpoint menjadi banyak domain menimbulkan masalah baru: client harus memanggil 4–5 endpoint dan menggabungkan hasilnya sendiri. Ini memindahkan kompleksitas dari backend ke frontend — bukan solusi.
Backend for Frontend (BFF) menyelesaikan ini dengan menjadi satu titik masuk untuk client, tapi melakukan parallel fetch ke domain endpoint secara internal.
graph LR
Client["Client"] -->|"1 request"| BFF["BFF Layer"]
BFF -->|"Parallel fetch"| D1["Detail Service"]
BFF -->|"Parallel fetch"| D2["Price Service"]
BFF -->|"Parallel fetch"| D3["Review Service"]
BFF -->|"Parallel fetch"| D4["Recommendation Service"]
D1 & D2 & D3 & D4 -->|"Aggregated response"| BFF
BFF -->|"1 response"| Client
style Client fill:#f5f5f4,stroke:#78716c,color:#000
style BFF fill:#e0e7ff,stroke:#4f46e5,color:#000
style D1 fill:#bbf7d0,stroke:#16a34a,color:#000
style D2 fill:#bbf7d0,stroke:#16a34a,color:#000
style D3 fill:#bbf7d0,stroke:#16a34a,color:#000
style D4 fill:#bbf7d0,stroke:#16a34a,color:#000Tanggung jawab BFF melampaui aggregasi:
| Tanpa BFF | Dengan BFF | |
|---|---|---|
| Fetching | Client panggil semua endpoint | BFF parallel fetch internal |
| Timeout | Client handle per endpoint | BFF isolasi timeout per domain |
| Partial failure | Client tampilkan error | BFF fallback ke default value |
| Versioning | Client update saat API berubah | BFF absorb perubahan internal |
| Perubahan backend | Bocor ke frontend | Tersembunyi di balik BFF |
Client tetap satu request, backend tetap modular. Inilah yang membuat sistem bisa berkembang tanpa memaksa update di sisi client.
Strategi Cache per Domain
Berikut strategi cache yang proven untuk setiap domain di product page:
| Domain | Cache Key | TTL | Invalidasi |
|---|---|---|---|
| Product detail | product:detail:{id} | 10–30 menit | Event ProductUpdated |
| Harga / Stok | product:price:{id}:{variant} | 5–10 detik | Event PriceChanged / StockUpdated |
| Latest reviews | product:reviews:latest:{id} | 30–120 detik | TTL (append-only, tidak perlu invalidasi) |
| Rekomendasi | product:reco:{id}:{segment} | 5–15 detik | TTL atau stale-while-revalidate |
Stale-while-revalidate adalah strategi terbaik untuk data yang bisa sedikit stale tapi harus selalu cepat: serve dari cache dulu (meski sudah expired), lalu refresh di background. Cocok untuk harga dan stok di mana user lebih toleran 1–2 detik delay dibanding halaman blank.
Elasticsearch — Kapan Dibutuhkan, Kapan Tidak
Elasticsearch sering dianggap “solusi cepat” saat database mulai lambat. Padahal, Elasticsearch bukan pengganti cache dan bukan solusi murah untuk primary-key lookup.
graph LR
subgraph "❌ Salah Kaprah: ES untuk Detail by ID"
DB3["DB mulai berat"] --> ES1["Pindah ke Elasticsearch"]
ES1 --> P1["Konsistensi eventual<br/>sulit dijelaskan ke bisnis"]
ES1 --> P2["Operational cost tinggi<br/>(cluster, shard, reindex)"]
ES1 --> P3["Debug data mismatch<br/>memakan waktu"]
end
style DB3 fill:#f5f5f4,stroke:#78716c,color:#000
style ES1 fill:#fecaca,stroke:#dc2626,color:#000
style P1 fill:#fecaca,stroke:#dc2626,color:#000
style P2 fill:#fecaca,stroke:#dc2626,color:#000
style P3 fill:#fecaca,stroke:#dc2626,color:#000| Use Case | Pakai Elasticsearch? | Alternatif |
|---|---|---|
GET /product/{id} — lookup by ID | ❌ Tidak perlu | Redis cache + DB |
| Full-text search produk | ✅ Sangat cocok | — |
| Filter kompleks (kategori, harga, brand) | ✅ Sangat cocok | — |
| Listing dengan sorting dinamis | ✅ Cocok | — |
| Aggregation (jumlah produk per kategori) | ✅ Cocok | — |
Aturan sederhananya: masukkan Elasticsearch karena kebutuhan query, bukan karena database terasa berat. Jika masalahnya adalah read QPS tinggi untuk detail by ID, solusinya adalah cache — bukan search engine.
Anti-Pattern yang Paling Sering Terjadi
Endpoint monolitik dengan TTL paling rendah. Satu GET /product/{id} yang mengambil semua data sekaligus terlihat simpel di awal, tapi menjadi masalah besar saat traffic naik. Cache seluruh endpoint harus di-invalidate setiap kali stok berubah — yang bisa terjadi ratusan kali per menit untuk produk populer.
Side effect sinkron di read path. Increment view counter, insert analytics event, dan update last_viewed yang dilakukan di dalam handler read API membuat read path kehilangan sifat deterministiknya. Latency naik seiring traffic karena setiap read menyebabkan beberapa write ke database.
Database sebagai pengganti cache. Menambah index terus, mengoptimasi query, tapi traffic read tetap langsung ke database. Database bukan komponen yang dirancang untuk ribuan concurrent read per detik terhadap data yang sama — itulah yang dilakukan cache.
Elasticsearch terlalu dini. DB mulai lambat → langsung migrasi ke Elasticsearch untuk GET /product/{id}. Ini menambah kompleksitas operasional (cluster management, reindexing), memperkenalkan eventual consistency yang sulit dijelaskan ke stakeholder, dan tidak menyelesaikan masalah aslinya — absennya cache yang efektif.
Orkestrasi di client. Frontend memanggil 5–6 endpoint, menggabungkan response, dan meng-handle timeout per domain. Saat satu endpoint lambat, seluruh UX terdampak. Logic bisnis mulai tersebar di client. Ini adalah gejala tidak adanya BFF layer.
Urutan Evolusi yang Bijak
Kesalahan umum adalah langsung melompat ke arsitektur paling kompleks tanpa bukti bahwa kompleksitas tersebut dibutuhkan. Setiap evolusi harus dipicu oleh bottleneck yang terbukti, bukan asumsi.
graph TD
S1["Tahap 1<br/>Satu endpoint sederhana<br/>Index DB yang memadai"]
S2["Tahap 2<br/>Pisahkan side effect ke async<br/>Tambahkan Redis cache"]
S3["Tahap 3<br/>Split endpoint per domain<br/>TTL per karakteristik data"]
S4["Tahap 4<br/>Tambahkan BFF layer<br/>CDN / Edge cache"]
S5["Tahap 5<br/>Read model terpisah<br/>Denormalized / materialized view"]
S6["Tahap 6<br/>Search index (ES/OpenSearch)<br/>Jika use case memang search-driven"]
S1 -->|"Cache hit rate rendah<br/>DB mulai terasa berat"| S2
S2 -->|"Cache churn tinggi<br/>TTL tidak efektif"| S3
S3 -->|"Client complexity meningkat<br/>Banyak endpoint dimanage"| S4
S4 -->|"Query pattern semakin kompleks<br/>Butuh denormalisasi"| S5
S5 -->|"Butuh full-text search<br/>Filter kompleks"| S6
style S1 fill:#f5f5f4,stroke:#78716c,color:#000
style S2 fill:#fef3c7,stroke:#d97706,color:#000
style S3 fill:#fde68a,stroke:#d97706,color:#000
style S4 fill:#bfdbfe,stroke:#2563eb,color:#000
style S5 fill:#bfdbfe,stroke:#2563eb,color:#000
style S6 fill:#bbf7d0,stroke:#16a34a,color:#000Banyak sistem berhasil di skala besar hanya dengan Tahap 2–3. Tahap 5 dan 6 hanya diperlukan jika masalah spesifik sudah muncul dan terbukti.
Kapan Arsitektur Ini Belum Perlu
Arsitektur read-heavy yang kompleks bisa menjadi beban jika diterapkan terlalu dini. Tanda-tanda bahwa arsitektur sederhana masih cukup:
- Traffic masih rendah dan database belum menunjukkan tanda-tanda bottleneck
- Product page jarang diakses bersamaan oleh banyak user
- Data masih sering berubah struktur — terlalu dini untuk denormalisasi
- Tim kecil dan fokus pada validasi produk dan market fit
- Belum ada observability yang cukup untuk mengukur cache hit rate dan query latency
Pada fase ini, solusi yang lebih tepat adalah satu endpoint sederhana, query yang jelas dengan index yang memadai, dan cache minimal. Arsitektur yang baik adalah yang cukup — bukan yang paling canggih.
Decision Guide — Pilih Strategi yang Tepat
graph TD
Start["Product page mulai<br/>terasa lambat"] --> Q1{"Bottleneck<br/>sudah teridentifikasi?"}
Q1 -->|"Belum"| Measure["Ukur dulu:<br/>query time, cache hit rate,<br/>DB connection pool"]
Q1 -->|"Ya"| Q2{"Di mana bottleneck-nya?"}
Q2 -->|"DB write contention<br/>saat read"| AsyncFix["Keluarkan side effect<br/>ke async queue"]
Q2 -->|"DB kena banyak<br/>read untuk data sama"| CacheFix["Tambahkan Redis cache<br/>dengan TTL tepat"]
Q2 -->|"Cache churn tinggi,<br/>TTL tidak efektif"| SplitFix["Split endpoint<br/>per domain data"]
Q2 -->|"Client complexity<br/>meningkat"| BFFFix["Tambahkan BFF layer"]
Q2 -->|"Butuh full-text search<br/>atau filter kompleks"| ESFix["Baru pertimbangkan<br/>Elasticsearch"]
style Measure fill:#fef3c7,stroke:#d97706,color:#000
style AsyncFix fill:#bbf7d0,stroke:#16a34a,color:#000
style CacheFix fill:#bbf7d0,stroke:#16a34a,color:#000
style SplitFix fill:#bbf7d0,stroke:#16a34a,color:#000
style BFFFix fill:#bbf7d0,stroke:#16a34a,color:#000
style ESFix fill:#bfdbfe,stroke:#2563eb,color:#000Ringkasan
- Read path harus murni — tidak ada side effect (view counter, analytics, last viewed) di dalam handler read API. Semua side effect dikeluarkan ke async queue.
- Cache adalah desain, bukan optimasi — dirancang dari awal bersama API contract, bukan ditempel setelah sistem berjalan. Target: 80–95% traffic berhenti di cache.
- Endpoint monolitik = TTL paling pendek menentukan segalanya — pisahkan endpoint per domain data supaya setiap domain bisa punya TTL dan strategi invalidasi sendiri.
- Composable Read APIs — satu endpoint per domain: detail (10–30 menit), price (5–10 detik), reviews (30–120 detik), recommendations (5–15 detik).
- BFF menyembunyikan kompleksitas — client tetap satu request, BFF yang parallel fetch ke domain endpoint, handle timeout isolation, dan berikan fallback saat partial failure.
- Elasticsearch bukan pengganti cache — masukkan karena kebutuhan query (full-text search, filter kompleks), bukan karena database terasa berat untuk primary-key lookup.
- Evolusi bertahap — async side effects → Redis cache → split endpoint → BFF → read model → search index. Setiap tahap dipicu bottleneck yang terbukti, bukan asumsi.
- Arsitektur yang baik adalah yang cukup — jangan terapkan kompleksitas ini sebelum bottleneck terbukti ada. Premature optimization lebih mahal dari masalah yang dicoba diselesaikan.