Komposisi backend adalah cara menyusun layanan seperti database, queue, cache, worker, lock, dan observability agar setiap komponen punya tanggung jawab jelas, mudah diuji, dan aman saat dijalankan di produksi. Masalah utama yang sering muncul bukan hanya memilih Redis, PostgreSQL, Kafka, atau library queue tertentu, tetapi bagaimana komponen tersebut dimulai, dihentikan, saling bergantung, menangani kegagalan, dan tetap konsisten saat beban naik.

Artikel ini terinspirasi dari ide system composition seperti pada Biff.core: aplikasi dipandang sebagai kumpulan komponen yang eksplisit, bukan sekumpulan global singleton tersembunyi. Contohnya tidak terikat pada framework tertentu; fokusnya adalah pola desain yang bisa diterapkan di banyak stack backend.

1. Prinsip Komposisi Backend yang Rapi

Komposisi backend yang baik biasanya memiliki tiga karakteristik:

  • Lifecycle eksplisit: setiap komponen tahu cara start dan stop.
  • Dependency eksplisit: worker membutuhkan database, queue, logger, dan cache melalui parameter, bukan mengambil dari global state.
  • Batas tanggung jawab jelas: queue tidak berisi logika bisnis, cache tidak menjadi sumber kebenaran utama, lock tidak menggantikan constraint database.

Dengan pendekatan ini, struktur aplikasi menjadi lebih mudah dipahami. Ketika terjadi masalah, Anda bisa bertanya: apakah error berasal dari koneksi database, konsumsi queue, invalidasi cache, lock yang tidak dilepas, atau worker yang belum berhenti dengan benar?

Contoh peta komponen sederhana

Contoh berikut menunjukkan pola umum. Ini bukan API dari framework tertentu, melainkan pseudo-code untuk menggambarkan ide komposisi.

function buildSystem(env) {
  const logger = createLogger({ level: env.LOG_LEVEL })
  const metrics = createMetrics({ prefix: 'billing_api' })

  const db = createDatabase({ url: env.DATABASE_URL, logger })
  const cache = createCache({ url: env.REDIS_URL, logger, metrics })
  const lock = createLockManager({ cache, logger })
  const queue = createQueue({ url: env.REDIS_URL, logger, metrics })

  const services = createServices({ db, cache, lock, logger, metrics })
  const workers = createWorkers({ queue, services, logger, metrics })
  const http = createHttpServer({ services, logger, metrics })

  return createSystem([
    logger,
    metrics,
    db,
    cache,
    lock,
    queue,
    workers,
    http
  ])
}

Urutan ini penting. Komponen yang menjadi dependency harus siap sebelum komponen yang memakainya dijalankan. Saat berhenti, urutannya biasanya dibalik: hentikan HTTP dan worker terlebih dahulu, lalu tutup queue, cache, database, dan terakhir logger/metrics.

2. Lifecycle Start/Stop dan Graceful Shutdown

Backend yang rapi tidak hanya memikirkan cara memulai proses, tetapi juga cara menghentikannya. Graceful shutdown diperlukan agar request yang sedang berjalan tidak terputus secara kasar, job tidak diproses setengah jalan, dan koneksi database tidak ditutup sebelum transaksi selesai.

Urutan start yang disarankan

  1. Load konfigurasi dan validasi environment.
  2. Inisialisasi logger dan metrics.
  3. Buka koneksi database, cache, dan message broker.
  4. Jalankan migrasi atau health check ringan jika diperlukan.
  5. Daftarkan handler job dan service.
  6. Mulai worker.
  7. Mulai HTTP server atau consumer publik lainnya.

Urutan stop yang disarankan

  1. Berhenti menerima request atau job baru.
  2. Tunggu request dan job aktif selesai sampai batas waktu tertentu.
  3. Jika timeout tercapai, batalkan pekerjaan yang aman untuk dibatalkan.
  4. Tutup worker dan queue consumer.
  5. Flush metrics/log jika sistem observability membutuhkannya.
  6. Tutup koneksi cache dan database.
async function startSystem(components) {
  const started = []

  try {
    for (const component of components) {
      await component.start()
      started.push(component)
    }
  } catch (error) {
    for (const component of started.reverse()) {
      await safeStop(component)
    }
    throw error
  }
}

async function stopSystem(components) {
  for (const component of [...components].reverse()) {
    await safeStop(component)
  }
}

async function safeStop(component) {
  try {
    await component.stop()
  } catch (error) {
    console.error('failed to stop component', component.name, error)
  }
}

Pola di atas mencegah aplikasi berada dalam kondisi setengah hidup. Jika database gagal start, worker dan HTTP server tidak ikut berjalan. Jika satu komponen gagal dihentikan, komponen lain tetap mendapat kesempatan untuk berhenti.

Catatan: jangan mengandalkan mekanisme shutdown default runtime saja. Tangani sinyal proses seperti SIGTERM/SIGINT sesuai runtime dan platform deployment Anda, terutama di container orchestration.

3. Dependency Injection Sederhana Tanpa Overengineering

Dependency injection tidak harus berarti memakai container kompleks. Pada banyak backend, cukup kirimkan object dependency secara eksplisit ke service dan worker. Tujuannya adalah membuat dependency terlihat, mudah diganti saat test, dan tidak tersebar sebagai singleton global.

function createInvoiceService({ db, cache, lock, logger, metrics }) {
  return {
    async markPaid({ invoiceId, paymentId }) {
      return lock.withLock('invoice:' + invoiceId, async () => {
        const invoice = await db.invoices.findById(invoiceId)

        if (!invoice) {
          throw new Error('invoice_not_found')
        }

        if (invoice.status === 'paid') {
          return invoice
        }

        const updated = await db.transaction(async (tx) => {
          await tx.payments.insertOnce({ paymentId, invoiceId })
          return tx.invoices.updateStatus(invoiceId, 'paid')
        })

        await cache.delete('invoice:' + invoiceId)
        metrics.increment('invoice_paid_total')
        logger.info({ invoiceId }, 'invoice marked as paid')

        return updated
      })
    }
  }
}

Beberapa hal penting dari contoh ini:

  • Service menerima dependency dari luar, sehingga bisa diuji dengan fake database/cache.
  • Lock membungkus bagian kritis, tetapi database tetap menjadi sumber kebenaran utama.
  • Operasi idempotensi seperti insertOnce harus ditopang constraint unik di database, bukan hanya pengecekan di aplikasi.
  • Cache dihapus setelah perubahan data, bukan diperbarui sembarangan tanpa strategi konsistensi yang jelas.

4. Queue, Worker, Idempotensi, Retry, dan Dead-Letter

Queue berguna untuk pekerjaan yang tidak harus selesai dalam request utama: kirim email, generate laporan, sinkronisasi data, proses pembayaran lanjutan, atau webhook. Namun queue juga memperkenalkan kemungkinan duplikasi, keterlambatan, retry berulang, dan urutan job yang tidak selalu sesuai harapan.

Batas tanggung jawab queue dan worker

  • Queue: menyimpan pesan, mengatur jadwal eksekusi, retry metadata, dan visibility/acknowledgement sesuai kemampuan broker.
  • Worker: mengambil job, memanggil service, menangani error, mencatat metrics, dan melakukan ack/nack sesuai hasil.
  • Service: menjalankan aturan bisnis dan transaksi database.

Jangan menaruh logika bisnis langsung di format pesan queue. Pesan job sebaiknya kecil dan stabil, misalnya berisi ID entity, bukan snapshot data besar yang mudah basi.

queue.register('send-payment-receipt', async (job, ctx) => {
  const { paymentId } = job.payload

  await ctx.services.receipt.sendForPayment({
    paymentId,
    idempotencyKey: job.id
  })
})

Idempotensi job

Worker harus diasumsikan bisa menjalankan job yang sama lebih dari sekali. Penyebabnya bisa timeout, worker crash setelah side effect tetapi sebelum ack, retry manual, atau race condition saat deployment.

Strategi idempotensi yang umum:

  • Unique constraint: simpan job_id, payment_id, atau idempotency_key pada tabel hasil.
  • Status machine: proses entity berdasarkan status yang valid, misalnya pending -> processing -> paid.
  • Outbox pattern: tulis event ke tabel outbox dalam transaksi yang sama dengan perubahan data, lalu worker lain mengirimkannya.
  • External idempotency key: saat memanggil API eksternal yang mendukung idempotency key, gunakan key stabil dari sistem Anda.

Retry dan dead-letter

Tidak semua error layak di-retry. Bedakan error sementara dan permanen.

  • Error sementara: timeout jaringan, rate limit, koneksi database putus, service eksternal tidak tersedia.
  • Error permanen: payload tidak valid, entity tidak ditemukan karena bug data, konfigurasi salah, atau pelanggaran aturan bisnis.
workerPolicy:
  maxAttempts: 5
  retryBackoff: exponential_with_jitter
  initialDelaySeconds: 10
  maxDelaySeconds: 900
  deadLetterQueue: payment_jobs_dead
  jobTimeoutSeconds: 120
  concurrency: 8

Gunakan exponential backoff dengan jitter agar ribuan job tidak mencoba ulang pada detik yang sama. Setelah jumlah percobaan habis, pindahkan job ke dead-letter queue. Dead-letter bukan tempat sampah; ia harus dimonitor, memiliki dashboard, dan bisa diproses ulang setelah penyebab masalah diperbaiki.

Common mistake pada queue

  • Memakai queue sebagai pengganti transaksi database.
  • Mengirim payload besar yang berisi data mudah berubah.
  • Tidak mencatat attempt count, error terakhir, dan waktu kegagalan.
  • Retry error permanen sampai memenuhi queue.
  • Tidak membuat job idempotent.

5. Cache dan Cache Invalidation

Cache mempercepat pembacaan data, tetapi menambah kompleksitas konsistensi. Prinsip paling aman: database tetap menjadi sumber kebenaran, cache hanya salinan sementara yang boleh hilang kapan saja.

Pola cache yang umum

  • Cache-aside: aplikasi membaca cache dulu; jika miss, baca database lalu isi cache.
  • Write-through: aplikasi menulis ke cache dan database melalui jalur yang sama.
  • Read-through: mekanisme cache menangani miss secara transparan, biasanya lewat library atau layer khusus.

Untuk banyak aplikasi web, cache-aside cukup sederhana dan mudah dikontrol.

async function getInvoice({ invoiceId, db, cache }) {
  const key = 'invoice:' + invoiceId
  const cached = await cache.getJson(key)

  if (cached) {
    return cached
  }

  const invoice = await db.invoices.findById(invoiceId)

  if (!invoice) {
    return null
  }

  await cache.setJson(key, invoice, { ttlSeconds: 300 })
  return invoice
}

async function updateInvoiceStatus({ invoiceId, status, db, cache }) {
  const invoice = await db.invoices.updateStatus(invoiceId, status)
  await cache.delete('invoice:' + invoiceId)
  return invoice
}

Strategi invalidasi

  • Delete on write: setelah update database, hapus cache terkait. Pembacaan berikutnya akan mengisi ulang.
  • TTL: batasi umur cache agar data basi tidak bertahan terlalu lama.
  • Versioned key: sertakan versi atau timestamp pada key untuk data yang sulit diinvalidasi manual.
  • Event-based invalidation: gunakan event domain untuk menghapus banyak key terkait setelah perubahan data.

Trade-off utamanya adalah antara performa dan konsistensi. TTL panjang mengurangi beban database, tetapi meningkatkan risiko data basi. TTL pendek lebih segar, tetapi cache hit ratio turun. Untuk data sensitif seperti saldo, izin akses, atau status pembayaran, pertimbangkan membaca langsung dari database atau memakai cache dengan TTL sangat pendek dan validasi tambahan.

Masalah cache yang sering terjadi

  • Cache stampede: banyak request miss bersamaan lalu menghantam database. Mitigasi: request coalescing, lock singkat saat rebuild cache, jitter pada TTL.
  • Key tidak konsisten: beberapa jalur kode memakai format key berbeda.
  • Cache menyimpan data otorisasi terlalu lama: user yang sudah kehilangan akses masih bisa melihat data lama.
  • Tidak ada metrics: sulit tahu apakah cache benar-benar membantu atau justru menambah masalah.

6. Distributed Lock: Berguna, Tetapi Bukan Pengganti Konsistensi Database

Distributed lock digunakan untuk membatasi agar hanya satu proses menjalankan bagian tertentu pada satu waktu, misalnya memproses invoice yang sama, menjalankan scheduled job tunggal, atau mencegah cache rebuild serentak. Namun lock bukan pengganti constraint, transaksi, atau optimistic concurrency di database.

Kapan lock layak dipakai

  • Operasi mahal yang tidak boleh paralel untuk entity yang sama.
  • Scheduled task yang berjalan di banyak instance aplikasi.
  • Pengendalian cache stampede.
  • Koordinasi ringan antar worker.

Aturan praktis distributed lock

  • Gunakan key yang spesifik, misalnya invoice:123, bukan lock global jika tidak perlu.
  • Selalu pasang TTL agar lock bisa lepas jika proses crash.
  • Gunakan token unik saat acquire dan release agar proses lain tidak melepas lock yang bukan miliknya.
  • Jangan menahan lock terlalu lama; pecah pekerjaan besar menjadi unit kecil.
  • Tetap gunakan constraint database untuk mencegah duplikasi final.
async function withLock({ key, ttlMs, lockStore, logger }, fn) {
  const token = randomToken()
  const acquired = await lockStore.acquire(key, token, ttlMs)

  if (!acquired) {
    throw new Error('lock_not_acquired')
  }

  try {
    return await fn()
  } finally {
    await lockStore.releaseIfOwner(key, token)
      .catch((error) => logger.warn({ key, error }, 'failed to release lock'))
  }
}

Implementasi detail bergantung pada backend lock yang dipakai. Jika memakai Redis, pastikan operasi acquire bersifat atomik dan release memeriksa token pemilik. Jika memakai database, Anda bisa memanfaatkan row-level lock atau advisory lock sesuai kemampuan database. Pilih mekanisme yang sesuai dengan durasi kerja, karakteristik kegagalan, dan kebutuhan konsistensi.

7. Consistency Trade-off: Database, Queue, Cache, dan Lock

Saat backend terdiri dari banyak komponen, konsistensi tidak lagi otomatis. Ada jeda antara commit database, publish job, update cache, dan eksekusi worker. Anda perlu menentukan data mana yang harus konsisten kuat dan mana yang boleh eventually consistent.

Panduan keputusan

  • Saldo, pembayaran, stok kritis: utamakan transaksi database, constraint unik, dan isolation yang sesuai.
  • Email notifikasi: boleh eventually consistent, tetapi job harus idempotent.
  • Dashboard analitik: biasanya boleh tertunda beberapa menit, cocok untuk queue dan agregasi async.
  • Permission dan akses data: berhati-hati dengan cache; invalidasi harus cepat dan dapat diaudit.

Outbox untuk menghindari kehilangan event

Masalah klasik: aplikasi berhasil commit database, tetapi gagal mengirim job ke queue. Atau sebaliknya, job terkirim tetapi transaksi gagal. Salah satu solusi yang umum adalah outbox pattern.

  1. Dalam satu transaksi, update data bisnis dan tulis record event ke tabel outbox.
  2. Worker outbox membaca record yang belum dipublikasikan.
  3. Worker mengirim event/job ke queue.
  4. Jika sukses, tandai record outbox sebagai terkirim.

Pola ini tidak menghilangkan kebutuhan idempotensi, tetapi mengurangi risiko event hilang karena kegagalan di antara database dan queue.

8. Observability: Log, Metrics, Trace, dan Health Check

Komposisi backend yang rapi harus mudah diamati. Observability bukan tambahan kosmetik; ia adalah cara mengetahui apakah queue tertahan, cache tidak efektif, lock sering gagal, atau worker terlalu lambat.

Metrics minimum yang sebaiknya ada

  • Queue: panjang antrean, umur job tertua, jumlah retry, jumlah dead-letter, throughput job.
  • Worker: durasi eksekusi, success/failure count, timeout, concurrency aktif.
  • Cache: hit/miss ratio, latency, error rate, eviction jika tersedia.
  • Lock: jumlah acquire sukses/gagal, waktu tunggu lock, lock timeout.
  • Database: latency query, connection pool usage, deadlock, slow query.
  • HTTP: latency, status code, request rate, error rate.

Logging yang berguna

Gunakan structured log agar mudah dicari. Sertakan request_id, job_id, entity_id, attempt, dan nama worker. Hindari log berlebihan pada jalur panas, tetapi pastikan error menyimpan konteks yang cukup untuk debugging.

logger.info({
  jobId: job.id,
  jobName: job.name,
  attempt: job.attempt,
  paymentId: job.payload.paymentId
}, 'processing payment receipt job')

Health check dan readiness

  • Liveness: proses masih hidup dan event loop/runtime tidak macet.
  • Readiness: aplikasi siap menerima traffic; database, cache, dan dependency kritis bisa dijangkau.
  • Worker health: worker masih mengambil job, tidak stuck pada job lama, dan tidak menumpuk dead-letter.

Jangan membuat readiness check terlalu berat. Query kompleks pada setiap health check bisa menjadi sumber beban baru. Gunakan pengecekan ringan dan pantau metrics untuk gejala yang lebih detail.

9. Backpressure dan Pengendalian Beban

Backpressure adalah mekanisme agar sistem tidak menerima pekerjaan lebih cepat daripada kemampuan memprosesnya. Tanpa backpressure, queue akan tumbuh, retry memperparah beban, database kehabisan koneksi, dan latency meningkat.

Teknik backpressure praktis

  • Batasi concurrency worker berdasarkan kapasitas database dan service eksternal.
  • Rate limit producer jika antrean melewati ambang tertentu.
  • Gunakan circuit breaker untuk dependency eksternal yang sedang gagal.
  • Prioritaskan queue jika ada job kritis dan non-kritis.
  • Pause atau shed load untuk pekerjaan non-esensial saat sistem tertekan.
if (await queue.depth('email_jobs') > MAX_EMAIL_QUEUE_DEPTH) {
  logger.warn('email queue is full, delaying non-critical email')
  return { accepted: false, reason: 'backpressure' }
}

await queue.enqueue('send-email', { userId, templateId })

Backpressure harus terlihat oleh caller. Untuk API publik, bisa berupa status yang jelas, retry-after, atau fallback yang aman. Untuk proses internal, bisa berupa penundaan enqueue, pengurangan concurrency, atau pemindahan pekerjaan ke queue prioritas rendah.

10. Masalah Operasional Umum dan Cara Debugging

Queue menumpuk

  • Periksa apakah worker aktif dan terhubung ke broker.
  • Lihat durasi job: apakah ada job lambat yang memblokir concurrency?
  • Periksa dependency eksternal: apakah retry massal terjadi karena service lain down?
  • Bandingkan laju enqueue dan laju proses.
  • Naikkan concurrency hanya jika database dan dependency masih punya kapasitas.

Job masuk dead-letter terlalu banyak

  • Kelompokkan berdasarkan error message dan job name.
  • Bedakan error permanen dan sementara.
  • Validasi schema payload sebelum job diproses.
  • Pastikan deploy terbaru tidak mengubah format job secara tidak kompatibel.
  • Buat prosedur replay yang aman dan tetap idempotent.

Cache hit rendah

  • Periksa konsistensi format key.
  • Evaluasi TTL: terlalu pendek atau terlalu banyak invalidasi.
  • Pastikan data yang di-cache memang sering dibaca ulang.
  • Gunakan metrics per key pattern, bukan hanya global hit ratio.

Lock sering timeout

  • Periksa apakah critical section terlalu besar.
  • Pastikan lock dilepas dengan token pemilik yang benar.
  • Cek apakah TTL terlalu pendek atau terlalu panjang.
  • Gunakan key lebih spesifik agar tidak semua pekerjaan berebut lock yang sama.

Database kehabisan koneksi

  • Hitung total potensi koneksi: jumlah instance dikali pool size per instance.
  • Jangan menaikkan concurrency worker tanpa menyesuaikan pool dan kapasitas database.
  • Periksa query lambat yang menahan koneksi terlalu lama.
  • Gunakan timeout query dan transaksi yang pendek.

Checklist Implementasi Komposisi Backend

  • Setiap komponen memiliki fungsi start dan stop yang eksplisit.
  • Urutan start/stop terdokumentasi dan diuji minimal pada environment staging.
  • Dependency diberikan secara eksplisit ke service dan worker.
  • Job queue berisi payload kecil, stabil, dan berbasis ID.
  • Semua job penting idempotent dan ditopang constraint database bila perlu.
  • Retry memakai backoff dan membedakan error sementara vs permanen.
  • Dead-letter queue dimonitor dan memiliki prosedur replay yang aman.
  • Cache memiliki strategi invalidasi, TTL, dan metrics hit/miss.
  • Distributed lock memakai TTL, token pemilik, dan key yang spesifik.
  • Database tetap menjadi sumber kebenaran untuk data kritis.
  • Graceful shutdown menghentikan intake job/request sebelum menutup dependency.
  • Backpressure diterapkan pada producer, worker concurrency, atau API boundary.
  • Metrics minimum tersedia untuk queue, worker, cache, lock, database, dan HTTP.
  • Structured log menyertakan request ID, job ID, attempt, dan entity ID.
  • Health check membedakan liveness, readiness, dan kondisi worker.

Penutup

Komposisi backend yang rapi membuat queue, cache, worker, lock, database, dan observability bekerja sebagai sistem yang dapat dipahami, bukan kumpulan komponen yang saling bergantung secara tersembunyi. Kuncinya adalah lifecycle eksplisit, dependency injection sederhana, batas tanggung jawab yang jelas, dan asumsi realistis bahwa kegagalan, retry, data basi, serta duplikasi job akan terjadi.

Mulailah dari peta komponen kecil: database, cache, queue, worker, service, logger, dan metrics. Pastikan semuanya bisa start/stop dengan benar, lalu tambahkan idempotensi, retry policy, dead-letter, invalidasi cache, lock yang aman, dan backpressure. Dengan fondasi ini, backend lebih mudah dioperasikan saat skala dan kompleksitas bertambah.