Bug dependensi siklis pada service graph Clojure biasanya muncul dengan gejala yang terasa tidak langsung: proses startup macet, sebagian service tidak pernah siap, atau request tertentu timeout setelah refactor wiring modul. Masalah utamanya bukan selalu pada logika bisnis, melainkan pada relasi antar service yang tidak lagi membentuk directed acyclic graph (DAG).

Dalam codebase backend Clojure yang memodelkan komponen sebagai graph layanan, pendekatan ini sangat fleksibel karena dependensi bisa di-query, divisualisasikan, dan diinisialisasi secara terurut. Namun fleksibilitas itu juga membuka risiko: jika validasi graph lemah, perubahan kecil pada relasi node dapat menciptakan siklus atau urutan init yang salah. Artikel ini membahas studi kasus debugging semacam itu, terinspirasi dari konsep queryable graph seperti pada Biff.graph.

Studi kasus: startup macet setelah refactor wiring modul

Bayangkan sebuah backend Clojure memiliki beberapa service inti:

  • :config untuk membaca konfigurasi aplikasi
  • :db untuk koneksi database
  • :event-bus untuk publish/subscribe internal
  • :user-service untuk operasi domain user
  • :audit-service untuk pencatatan audit
  • :http-handler untuk menerima request

Sebelum refactor, relasi dependensinya masih sehat. Lalu ada perubahan wiring:

  • :user-service memakai :audit-service untuk mencatat perubahan user
  • :audit-service ikut memakai :user-service untuk enrich data actor

Perubahan ini terlihat kecil, tetapi kini graph punya relasi dua arah. Saat bootstrap berjalan, inisialisasi tidak pernah mencapai keadaan stabil karena masing-masing node menunggu node lain tersedia lebih dulu.

Gejala yang terlihat di produksi atau staging

  • Startup berhenti di log terakhir seperti initializing service :user-service
  • Health check gagal karena sebagian dependency belum siap
  • Request endpoint tertentu timeout karena service diinisialisasi secara lazy saat request pertama
  • Error yang muncul tidak eksplisit, misalnya hanya nil pointer, future yang tidak selesai, atau retry tanpa akhir

Ini yang membuat bug dependensi siklis sering menipu. Akar masalah ada di graph dependency, tetapi gejalanya bisa muncul sebagai deadlock logis, timeout, atau resource yang tampak tidak terpasang.

Mengapa service graph rentan terhadap bug ini

Pada arsitektur berbasis graph, setiap node biasanya merepresentasikan service atau komponen, dan edge menyatakan kebutuhan init atau runtime. Sistem bootstrap kemudian melakukan traversal untuk menentukan urutan pembuatan service. Selama graph adalah DAG, urutan ini bisa dihitung. Jika muncul siklus, urutan valid tidak ada.

Pola ini mirip dengan konsep graph yang bisa di-query: kita ingin bisa bertanya, misalnya, “service ini bergantung pada apa?”, “siapa yang memakai service ini?”, atau “node mana yang terisolasi?”. Keuntungannya besar untuk modularitas. Trade-off-nya, wiring menjadi data yang harus divalidasi dengan disiplin, bukan sekadar dibiarkan berkembang secara organik.

Dua root cause yang paling umum

  1. Siklus eksplisit
    Node A membutuhkan B, lalu B membutuhkan A, baik langsung maupun melalui beberapa node perantara.
  2. Urutan init salah meski belum siklis secara formal
    Sebuah service mengakses dependency saat fase bootstrap padahal dependency itu baru valid setelah lifecycle tertentu selesai. Secara deklaratif graph tampak benar, tetapi penggunaan aktualnya melanggar asumsi urutan siap pakai.

Kasus kedua penting: tidak semua bug wiring tampak sebagai cycle sederhana. Ada juga service yang terlalu dini memanggil efek samping dari dependency yang baru setengah jadi.

Langkah investigasi yang efektif

1. Reproduksi bug secara deterministik

Mulailah dengan membuat kasus reproduksi sekecil mungkin. Hindari men-debug langsung dari seluruh aplikasi jika wiring bisa diperkecil ke subset node terkait. Tujuannya adalah memastikan Anda tahu kapan bug muncul dan kapan hilang.

Praktik yang berguna:

  • Jalankan bootstrap hanya untuk modul yang relevan
  • Matikan service eksternal yang tidak berhubungan
  • Bekukan konfigurasi environment agar hasil konsisten
  • Tambahkan timeout pendek pada fase init agar hang cepat terlihat

Jika request tertentu timeout setelah deploy, coba reproduksi lewat startup dan lewat request path. Kadang cycle baru aktif saat lazy initialization dipicu oleh endpoint tertentu.

2. Log graph, bukan hanya log exception

Kesalahan umum saat debugging adalah fokus pada stack trace terakhir. Pada bug graph, Anda perlu melihat struktur dependency yang dibangun saat runtime.

Minimal, log setiap node beserta dependensinya sebelum bootstrap:

(defn log-service-graph [graph]
  (doseq [[node deps] graph]
    (println :service node :depends-on deps)))

Jika representasi graph Anda lebih kaya, log juga metadata seperti jenis node, fase lifecycle, dan factory function yang dipakai. Dengan begitu, Anda bisa membedakan antara:

  • dependency deklaratif untuk urutan init
  • reference runtime yang seharusnya tidak ikut mengunci bootstrap
  • resource opsional versus resource wajib

Catatan: bug sering terjadi karena semua relasi diperlakukan sama. Padahal tidak semua hubungan antar modul harus menjadi edge bootstrap.

3. Query node dan edge yang terlibat

Jika graph memang dirancang agar dapat di-query, manfaatkan itu. Pada tahap ini, pertanyaan yang membantu adalah:

  • Node ini bergantung ke siapa saja?
  • Node mana saja yang transitif bergantung pada node ini?
  • Apakah ada lintasan kembali ke node awal?
  • Adakah edge baru setelah refactor yang sebelumnya tidak ada?

Contoh fungsi sederhana untuk mengambil dependency langsung:

(defn deps-of [graph node]
  (get graph node []))

Dan untuk melacak dependency transitif secara DFS:

(defn transitive-deps [graph start]
  (loop [stack (seq (deps-of graph start))
         seen #{}]
    (if-let [node (first stack)]
      (if (contains? seen node)
        (recur (rest stack) seen)
        (recur (concat (rest stack) (deps-of graph node))
               (conj seen node)))
      seen)))

Jika hasil (transitive-deps graph :user-service) mengandung :user-service sendiri melalui lintasan tertentu, Anda hampir pasti punya cycle.

4. Deteksi cycle secara eksplisit

Jangan berharap cycle selalu mudah terlihat dari log. Tambahkan detektor cycle yang berjalan saat boot atau saat test. Pendekatan paling umum adalah DFS dengan penanda visiting dan visited.

(defn find-cycle [graph]
  (letfn [(visit [node visiting visited path]
            (cond
              (contains? visiting node)
              (conj path node)

              (contains? visited node)
              nil

              :else
              (some #(visit %
                             (conj visiting node)
                             (conj visited node)
                             (conj path node))
                    (deps-of graph node))))]
    (some #(visit % #{} #{} []) (keys graph))))

Implementasi di atas sengaja sederhana. Dalam codebase produksi, Anda biasanya ingin:

  • mengembalikan seluruh lintasan cycle, bukan node terakhir saja
  • menampilkan edge mana yang menyebabkan loop
  • membedakan cycle hard dependency dan soft dependency
  • menghentikan boot dengan error yang bisa ditindaklanjuti

Contoh error yang jauh lebih berguna:

Invalid service graph: dependency cycle detected
:user-service -> :audit-service -> :user-service

5. Minimalkan kasus sampai akar masalah jelas

Setelah cycle ditemukan, kurangi graph menjadi subgraph terkecil yang tetap gagal. Ini membantu memastikan Anda memperbaiki desain, bukan sekadar gejalanya.

Misalnya dari puluhan node, ternyata masalahnya cukup direduksi menjadi tiga relasi:

  • :http-handler -> :user-service
  • :user-service -> :audit-service
  • :audit-service -> :user-service

Begitu bentuk minimumnya jelas, keputusan refactor jadi lebih mudah.

Membedakan cycle desain dari kebutuhan bisnis yang sah

Tidak semua hubungan dua arah berarti desain salah secara domain. Kadang secara bisnis memang ada keterkaitan timbal balik. Yang salah adalah memetakan keterkaitan itu menjadi bootstrap dependency dua arah.

Contoh:

  • :user-service perlu mengirim event audit
  • :audit-service perlu informasi actor user

Ini tidak harus berarti kedua service saling menginisialisasi langsung. Anda bisa memecah relasi menjadi pola yang lebih aman.

Pola perbaikan 1: pisahkan interface dari implementasi

Alih-alih membuat :audit-service bergantung pada keseluruhan :user-service, ekstrak kontrak yang lebih sempit. Misalnya, audit hanya butuh resolver actor tertentu, bukan seluruh service user.

{:actor-resolver [:db]
 :audit-service [:actor-resolver :event-bus]
 :user-service [:audit-service :db]}

Dengan ini, Anda menghilangkan edge balik dari :audit-service ke :user-service. Relasi domain tetap ada, tetapi graph bootstrap tetap acyclic.

Pola perbaikan 2: ganti direct call dengan event atau callback

Jika audit hanya perlu mencatat aksi setelah perubahan user, :user-service bisa menerbitkan event. :audit-service cukup subscribe ke event tersebut, tanpa menjadi dependency init dua arah.

Trade-off-nya:

  • Kelebihan: coupling lebih longgar, graph lebih sehat
  • Kekurangan: alur data menjadi lebih tidak langsung, debugging butuh observability lebih baik

Pola perbaikan 3: dependency injection yang aman

Jangan injeksikan container atau graph penuh ke semua service, lalu biarkan setiap service mengambil node lain sesuka hati. Ini memang fleksibel, tetapi sangat mudah menimbulkan hidden dependency dan lazy cycle.

Lebih aman jika factory service menerima dependency eksplisit:

(defn make-user-service [{:keys [db audit-service]}]
  {:create-user (fn [user]
                  ;; simpan user
                  ;; kirim audit
                  )})

Dengan dependency eksplisit:

  • review wiring lebih mudah
  • test lebih sederhana
  • edge graph bisa dihitung dan divalidasi
  • akses tak terduga ke node lain lebih sulit terjadi

Perbaikan praktis yang sebaiknya dipasang permanen

Validasi DAG saat boot

Ini guardrail paling penting. Sebelum service diinisialisasi, validasi bahwa graph tidak punya cycle dan semua dependency wajib benar-benar ada.

(defn validate-graph! [graph]
  (when-let [cycle (find-cycle graph)]
    (throw (ex-info "Dependency cycle detected"
                    {:cycle cycle})))
  (doseq [[node deps] graph
          dep deps]
    (when-not (contains? graph dep)
      (throw (ex-info "Missing dependency"
                      {:node node :missing dep}))))
  graph)

Kenapa ini efektif? Karena Anda memindahkan kegagalan dari fase runtime yang kabur menjadi fase boot yang deterministik dan mudah dibaca.

Bedakan dependency init dan dependency runtime

Sering kali cycle muncul karena semua relasi dimasukkan ke satu daftar dependency. Padahal ada relasi yang hanya dibutuhkan saat request tertentu, bukan untuk membangun service.

Praktik yang lebih sehat:

  • punya metadata edge, misalnya :init dan :runtime
  • hanya edge :init yang dipakai untuk topological sort
  • edge runtime tetap boleh di-query untuk dokumentasi dan analisis

Dengan pemisahan ini, graph bootstrap tetap sederhana tanpa kehilangan visibilitas atas relasi runtime.

Tambahkan test regresi khusus graph

Jangan hanya mengandalkan test endpoint atau unit test business logic. Buat test yang secara khusus memverifikasi struktur service graph.

(deftest service-graph-should-be-acyclic
  (let [graph (build-service-graph test-config)]
    (is (nil? (find-cycle graph)))))

Anda juga bisa menambah test untuk memastikan node penting tidak bergantung pada layer yang salah, misalnya service domain tidak boleh bergantung langsung ke HTTP handler.

Pasang observability pada lifecycle init

Jika startup bermasalah, Anda ingin tahu node mana yang sedang dibangun, berapa lama durasinya, dan dependency apa yang belum siap.

Guardrail yang berguna:

  • log terstruktur per node: starting, ready, failed
  • durasi inisialisasi per service
  • dump subgraph saat boot gagal
  • health endpoint yang menampilkan service readiness internal

Ini sangat membantu membedakan apakah masalahnya cycle murni, dependency hilang, atau service eksternal yang lambat.

Pitfall yang sering terjadi saat memperbaiki bug ini

Memecah cycle dengan workaround yang menyembunyikan masalah

Contoh umum adalah menambahkan delay, future, atau lazy deref agar startup lolos. Ini kadang membuat boot berhasil, tetapi cycle logis tetap ada dan baru meledak saat request produksi.

Jika Anda memakai lazy initialization, pastikan itu keputusan desain yang sadar, bukan cara menyamarkan dependency yang salah.

Menganggap semua dependency sama penting

Dependency observability, metrics, atau notifier sering tidak perlu memblokir bootstrap utama. Jika semua komponen diperlakukan sebagai hard dependency, graph menjadi kaku dan rawan salah urut.

Membiarkan service mengambil dependency dari global state

Begitu service bebas mengakses registry global, edge graph formal bisa berbeda dari edge aktual runtime. Hasilnya, validasi DAG lulus, tetapi aplikasi tetap bisa masuk ke jalur siklis yang tidak terlihat di deklarasi.

Checklist debugging bug dependensi siklis dari service graph Clojure

  1. Reproduksi gejala secara konsisten: startup macet, init gagal, atau request timeout.
  2. Log struktur graph yang benar-benar dibangun saat runtime.
  3. Query node terkait dan telusuri dependency transitif.
  4. Jalankan deteksi cycle secara eksplisit.
  5. Reduksi ke subgraph minimal yang masih gagal.
  6. Perbaiki desain dengan memisahkan interface, event, atau edge init versus runtime.
  7. Tambahkan validasi DAG saat boot.
  8. Tutup dengan test regresi dan observability lifecycle.

Penutup

Debug backend untuk bug dependensi siklis dari service graph Clojure pada dasarnya adalah pekerjaan membaca struktur, bukan hanya membaca stack trace. Saat codebase memodelkan modul sebagai graph layanan yang dapat di-query, keuntungan arsitekturalnya besar, tetapi hanya jika relasinya divalidasi dan dijaga tetap eksplisit.

Perbaikan yang paling tahan lama biasanya bukan sekadar menghilangkan satu edge, melainkan memperjelas kontrak antar service: mana yang benar-benar dependency bootstrap, mana yang hanya kolaborasi runtime, dan mana yang sebaiknya dipisah menjadi interface kecil atau event. Begitu graph divalidasi sebagai DAG saat boot, disertai test regresi dan observability yang memadai, kelas bug ini jauh lebih mudah dicegah daripada dikejar saat sistem sudah timeout di produksi.