Pada aplikasi yang tersusun berlapis, galat datang dari tempat yang berbeda-beda: fetch bisa gagal, server bisa menjawab 404, data bisa berbentuk tak terduga, dan tampilan bisa salah membaca hasilnya. Tanpa kebijakan yang disepakati, setiap developer menulis blok try-catch dengan caranya sendiri — ada yang menampilkan pesan teknis di UI, ada yang menelan galat tanpa jejak, dan ada yang membiarkannya naik sampai aplikasi berhenti. Chapter Error Boundaries sudah memberi prinsipnya: tangani bila tahu cara memulihkan, teruskan bila tidak. Chapter ini menerapkan prinsip itu menjadi kebijakan tertulis per lapisan.

Kebijakan yang kita bangun mengalir pada tiga lapisan — data → service → UI — dan memakai class ApiError dari chapter api-error-handling pada section Network dan API sebagai galat khasnya. Seluruh alurnya teruji di berkas contoh dengan server tiruan, sehingga keluaran ketiga skenarionya bisa dibandingkan baris demi baris.

Tanpa kebijakan semacam ini, kegagalan biasanya menderita dua kali. Pertama di tempat kemunculannya — ditampilkan atau diabaikan tanpa petunjuk. Lalu lagi di tempat lain, karena pemanggil tidak tahu galatnya sudah ditangani, sehingga membungkusnya dengan try-catch kedua sebagai penjaganya. Blok penjaga yang menumpuk inilah yang membuat pembaruan kecil terasa berisiko: tidak jelas blok mana yang bekerja dan blok mana yang tak pernah terpanggil. Kebijakan yang tertulis per lapisan menjawabnya dengan satu tanggung jawab yang tidak tumpang-tindih.

Kebijakan per Lapisan #

Setiap lapisan punya satu tugas dan satu keputusan tentang galat. Ringkasannya:

LapisanTugasnyaTangani galat dengan caraTeruskan ke atas
DataMemanggil sumber luar (fetch)Membungkus kegagalan menjadi ApiError — satu pintuSemua, selalu
ServiceLogika fitur (ambil produk)Menerjemahkan ApiError menjadi pesan ramah fiturYang tak dikenalnya
UIMenampilkan hasilMenampilkan message terakhir — tidak menebak jenis galatTidak ada; alur berhenti di sini

Perhatikan arah pengetahuannya: lapisan data tahu status HTTP tetapi tidak tahu konteks fitur; lapisan service tahu konteks fitur tetapi tidak tahu cara menampilkan; lapisan UI tahu tampilan tetapi tidak tahu detail jaringan. Karena tidak ada yang tahu semuanya, galat harus naik sambil berubah bentuk: status mentah di lapisan data, pesan fitur di service, teks siap tampil di UI.

Rantai perubahan bentuk itu bisa diikuti pada satu contoh: server menjawab 404 dengan body { "error": { "code": "PRODUK_TIDAK_DITEMUKAN" } }. Lapisan data membungkusnya menjadi ApiError dengan status: 404 — masih mentah, tetapi sudah punya nama jenis. Lapisan service membaca kodenya dan membungkus ulang menjadi Error dengan pesan “Produk yang dicari tidak tersedia” — kini dalam bahasa fitur, dan galat aslinya tersimpan di cause. Lapisan UI akhirnya hanya mencetak message. Satu kejadian, tiga wujud, dan setiap wujud ditulis oleh lapisan yang tepat.

Berkas Contoh: Satu Alur, Tiga Lapisan #

Berkas berikut menulis ketiga lapisan secara lengkap, lalu menjalankan tiga skenario: permintaan sukses, 404 dengan kode galat, dan 500 tanpa body. Fetch ditirukan lewat parameter (pola injeksi dari chapter sebelumnya) agar contoh bisa berjalan tanpa internet:

// alur-galat.mjs — kebijakan galat tiga lapis: data -> service -> UI.
// Jalankan di terminal: node alur-galat.mjs (tanpa internet; fetch ditirukan)

// class ApiError berasal dari chapter api-error-handling (section Network dan API).
// Versinya ditulis ulang di sini agar berkas contoh dapat berjalan mandiri.
class ApiError extends Error {
  constructor(status, body) {
    super(body?.error?.message ?? "Respons tidak standar dari server");
    this.name = "ApiError";
    this.status = status;
    this.body = body;
  }
}

// ===== Lapisan data: satu pintu untuk semua permintaan =====
async function mintaJson(fetchFn, url) {
  const response = await fetchFn(url);
  if (!response.ok) {
    const body = await response.json().catch(() => null);
    throw new ApiError(response.status, body); // satu-satunya tempat ApiError dibuat
  }
  return response.json();
}

// ===== Lapisan service: menerjemahkan galat menjadi bahasa fitur =====
const pesanRamah = new Map([
  ["PRODUK_TIDAK_DITEMUKAN", "Produk yang dicari tidak tersedia."],
  ["VALIDASI_GAGAL", "Periksa kembali isian formulir."],
]);

async function ambilProduk(fetchFn, id) {
  try {
    return await mintaJson(fetchFn, `/produk/${id}`);
  } catch (error) {
    if (error instanceof ApiError) {
      const kode = error.body?.error?.code;
      const ramah = pesanRamah.get(kode) ?? "Terjadi kendala pada server. Coba beberapa saat lagi.";
      throw new Error(ramah, { cause: error });
    }
    throw error; // galat lain diteruskan tanpa diubah
  }
}

// ===== Lapisan UI: menampilkan pesan, tidak lagi menebak jenis galat =====
async function tampilkanProduk(fetchFn, id) {
  try {
    const produk = await ambilProduk(fetchFn, id);
    console.log(`Menampilkan ${produk.nama} - Rp${produk.harga}`);
  } catch (error) {
    console.log(`Gagal: ${error.message}`);
  }
}

// ===== Server tiruan: 200 sukses, 404 berkode galat, 500 tanpa body =====
function buatFetchTiruan(rute) {
  return async (url) => {
    const respons = rute[url];
    if (!respons) {
      throw new Error(`Rute tiruan tidak terdaftar: ${url}`);
    }
    return {
      ok: respons.status < 400,
      status: respons.status,
      json: async () => respons.body,
    };
  };
}

const rute = {
  "/produk/1": { status: 200, body: { nama: "Mouse", harga: 85000 } },
  "/produk/99": {
    status: 404,
    body: { error: { code: "PRODUK_TIDAK_DITEMUKAN", message: "produk tidak ada" } },
  },
  "/produk/7": { status: 500, body: null },
};

const fetchTiruan = buatFetchTiruan(rute);

await tampilkanProduk(fetchTiruan, "1");
await tampilkanProduk(fetchTiruan, "99");
await tampilkanProduk(fetchTiruan, "7");

Output yang Dihasilkan:

text
Menampilkan Mouse - Rp85000
Gagal: Produk yang dicari tidak tersedia.
Gagal: Terjadi kendala pada server. Coba beberapa saat lagi.

Lapisan Data: Satu Pintu ApiError #

  1. Baris 6–13 (class ApiError extends Error): galat khas API membawa tiga informasi yang tidak dimiliki Error biasa — status, body, dan pesan dari server. Strukturnya sama persis dengan chapter api-error-handling; versi ditulis ulang agar berkas contoh mandiri.
  2. Baris 18 (if (!response.ok) {): satu-satunya pemeriksaan ok di seluruh berkas. Semua pemanggilan melewati mintaJson, sehingga kegagalan tidak mungkin lolos tanpa dibungkus ApiError — inilah pola satu pintu.
  3. Baris 19 (const body = await response.json().catch(() => null);): server yang sedang bermasalah kadang menjawab tanpa body JSON. .catch(() => null) memastikan pembacaan body yang gagal tidak menyembunyikan galat aslinya; ApiError tetap terbentuk dengan body null (lihat skenario ketiga).
  4. Baris 20 (throw new ApiError(response.status, body);): lapisan data tidak pernah menampilkan apa pun dan tidak memutuskan apa pun — tugasnya hanya membungkus. Keputusan tentang arti galat bukan wilayahnya.

Lapisan Service: Menerjemahkan, Bukan Menelan #

  1. Baris 26–29 (const pesanRamah = new Map([...])): pemetaan kode galat server ke pesan ramah hidup di service, karena arti kode itu adalah pengetahuan fitur. Kode yang tidak terdaftar jatuh ke nilai bawaan lewat ??.
  2. Baris 35 (if (error instanceof ApiError) {): service hanya menangani galat yang ia kenal. Inilah penerapan langsung aturan “tangani bila tahu” dari chapter Error Boundaries.
  3. Baris 37 (throw new Error(ramah, { cause: error });): galat dibungkus ulang: pesan barunya ramah untuk pengguna, sedangkan galat asli utuh di cause untuk diagnosis developer. Fitur cause dibahas di chapter error objects — di sini ia bekerja sebagai penghubung antar lapisan.
  4. Baris 39 (throw error;): galat yang bukan ApiError — misalnya bug program — diteruskan tanpa diubah dan tanpa ditelan. Service tidak berpura-pura tahu cara memulihkannya.

Lapisan UI: Pesan Terakhir #

  1. **Baris 48–49 (catch (error) { console.log(\Gagal: ${error.message}`); })**: UI hanya membaca message. Ia tidak memeriksa status, tidak membaca body, dan tidak perlu: pekerjaan menerjemahkan sudah selesai di bawahnya. Tiga skenario menghasilkan tiga message` berbeda dari satu baris yang sama. Pada aplikasi browser, baris yang sama semestinya menulis pesan itu ke elemen status di halaman — bentuk tampilannya berbeda, kebijakannya tetap: UI hanya menampilkan.
  2. Baris 54–66 (buatFetchTiruan): server tiruan mengembalikan object dengan bentuk minimum yang dipakai mintaJson — ok, status, dan json(). Bentuk minimum inilah kontrak dependensi; karena fetch disuntik lewat parameter, tiga skenario diuji tanpa satu byte pun melewati jaringan.

Satu catatan untuk galat yang tidak terduga — bug program, bukan kegagalan server. Kebijakan lapisan tidak mengubah nasibnya: ia tetap naik sampai ke atas, dan titik masuk aplikasi yang menyambutnya. Di situlah tempatnya dicatat (log penuh dengan stack) dan diberi tampilan paling umum (“Terjadi kendala, coba lagi”) — galat tak terduga tidak ditampilkan mentah kepada pengguna, tetapi juga tidak dihilangkan dari catatan developer.

Kebijakan seperti ini tidak menuntut alat baru; ia menuntut kesepakatan dan disiplin menjaganya. Setelah semua aturannya tertulis, pekerjaan harian berikutnya adalah yang paling sering dilakukan developer: mengubah kode yang sudah ada — merapikan struktur tanpa mengubah perilaku. Itulah topik chapter terakhir section ini, Refactoring JavaScript.