Arsitektur Read-Heavy API untuk Product Page Skala Tinggi
10 min read

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:#000

Target 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:#000
graph 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:#000

Side 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
LayerTeknologiCocok UntukTarget Hit Rate
CDN / EdgeCloudFront, FastlyResponse JSON/HTML stabil60–80%
Application CacheRedis, MemcachedHasil query per domain90–95% dari miss CDN
DatabasePostgreSQL, MySQLSource of truthHanya 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.

KomponenKarakteristikFrekuensi BerubahTTL Ideal
Product detailStabil (nama, deskripsi, gambar)Jarang (jam–hari)10–30 menit
Harga / StokSensitif, sering berubahSering (detik–menit)5–10 detik
Latest reviewsAppend-onlySedang (menit)30–120 detik
RekomendasiSangat dinamis per userSangat sering5–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:#000
graph 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:#000

Setiap 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?

KondisiRekomendasi
Data punya TTL ideal yang sangat berbedaSplit endpoint
Sebagian data berubah jauh lebih seringSplit endpoint
Kegagalan satu data tidak boleh merusak halamanSplit endpoint
Cache churn tinggi dan DB load meningkatSplit endpoint
Traffic masih rendah, data belum stabilTunda split, satu endpoint dulu
Tim kecil, fokus validasi produkTunda 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:#000

Tanggung jawab BFF melampaui aggregasi:

Tanpa BFFDengan BFF
FetchingClient panggil semua endpointBFF parallel fetch internal
TimeoutClient handle per endpointBFF isolasi timeout per domain
Partial failureClient tampilkan errorBFF fallback ke default value
VersioningClient update saat API berubahBFF absorb perubahan internal
Perubahan backendBocor ke frontendTersembunyi 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:

DomainCache KeyTTLInvalidasi
Product detailproduct:detail:{id}10–30 menitEvent ProductUpdated
Harga / Stokproduct:price:{id}:{variant}5–10 detikEvent PriceChanged / StockUpdated
Latest reviewsproduct:reviews:latest:{id}30–120 detikTTL (append-only, tidak perlu invalidasi)
Rekomendasiproduct:reco:{id}:{segment}5–15 detikTTL 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 CasePakai Elasticsearch?Alternatif
GET /product/{id} — lookup by ID❌ Tidak perluRedis 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:#000

Banyak 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:#000

Ringkasan

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