Eksepsi #
Setiap program yang berinteraksi dengan dunia nyata — membaca file, memanggil API, menerima input pengguna — pasti menghadapi kondisi yang tidak terduga. File tidak ada. Koneksi database timeout. Pengguna mengetik huruf di kolom yang seharusnya angka. Exception adalah cara Java memodelkan kondisi-kondisi ini sebagai objek yang bisa ditangkap, diinspeksi, dan ditangani secara terstruktur. Tanpa exception handling, satu error kecil bisa menghentikan seluruh program. Dengan exception handling yang baik, program bisa pulih dari kesalahan, mencatat informasi yang berguna untuk debugging, dan memberi tahu pengguna dengan pesan yang masuk akal. Artikel ini membahas exception dari dasar hirarki, cara menangkap dan melempar, hingga pola yang dipakai di kode produksi nyata.
Hirarki Exception #
Semua exception di Java adalah objek. Mereka mewarisi dari satu kelas puncak: Throwable. Memahami hirarki ini penting karena menentukan bagaimana kamu harus menangani setiap jenis error.
flowchart TD
A[Throwable] --> B[Error]
A --> C[Exception]
B --> D[OutOfMemoryError]
B --> E[StackOverflowError]
B --> F[VirtualMachineError]
C --> G[RuntimeException\nUnchecked]
C --> H[IOException\nChecked]
C --> I[SQLException\nChecked]
G --> J[NullPointerException]
G --> K[ArrayIndexOutOfBoundsException]
G --> L[IllegalArgumentException]
G --> M[ArithmeticException]
Ada tiga kategori utama yang perlu kamu bedakan:
| Kategori | Contoh | Wajib ditangani? | Penyebab umum |
|---|---|---|---|
| Error | OutOfMemoryError, StackOverflowError |
Tidak | JVM kehabisan resource — biasanya tidak bisa di-recover |
| Checked Exception | IOException, SQLException |
Ya | Kondisi eksternal yang bisa diantisipasi (file hilang, DB down) |
| Unchecked Exception | NullPointerException, IllegalArgumentException |
Tidak | Bug dalam logika program — seharusnya dicegah, bukan ditangkap |
Error tidak perlu dan tidak boleh kamu tangkap — saat JVM kehabisan memori, tidak ada yang bisa kamu lakukan. Checked exception harus kamu tangkap atau deklarasikan dengan throws. Unchecked exception (subkelas RuntimeException) adalah sinyal bug — lebih baik perbaiki kodemu daripada tangkap exception-nya.
try-catch: Menangkap Exception #
Blok try-catch adalah struktur dasar exception handling. Kode yang mungkin melempar exception diletakkan di dalam try, dan penanganannya di dalam catch.
public class ContohTryCatch {
public static void main(String[] args) {
// ANTI-PATTERN: tidak ada penanganan sama sekali
// int[] angka = {1, 2, 3};
// System.out.println(angka[5]);
// → program crash dengan stack trace yang menakutkan
// BENAR: tangkap exception dan tangani dengan bermakna
int[] angka = {1, 2, 3};
try {
System.out.println("Elemen ke-4: " + angka[3]);
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("Indeks tidak valid. Array hanya punya " + angka.length + " elemen.");
}
System.out.println("Program terus berjalan.");
}
}
Blok catch menerima objek exception sebagai parameter. Dari objek ini kamu bisa mengambil informasi penting:
try {
String teks = null;
System.out.println(teks.length()); // NullPointerException
} catch (NullPointerException e) {
System.out.println("Pesan: " + e.getMessage());
System.out.println("Tipe: " + e.getClass().getName());
e.printStackTrace(); // cetak seluruh call stack ke stderr — berguna untuk debugging
}
try-catch-finally: Membersihkan Resource #
Blok finally dijalankan selalu — baik exception terjadi maupun tidak. Ini adalah tempat yang tepat untuk menutup resource seperti file, koneksi database, atau stream jaringan.
import java.io.*;
public class ContohFinally {
public static void bacaFile(String namaFile) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(namaFile));
String baris;
while ((baris = reader.readLine()) != null) {
System.out.println(baris);
}
} catch (FileNotFoundException e) {
System.out.println("File tidak ditemukan: " + namaFile);
} catch (IOException e) {
System.out.println("Gagal membaca file: " + e.getMessage());
} finally {
// Selalu dijalankan — pastikan reader ditutup
if (reader != null) {
try {
reader.close();
System.out.println("File ditutup.");
} catch (IOException e) {
System.out.println("Gagal menutup file: " + e.getMessage());
}
}
}
}
public static void main(String[] args) {
bacaFile("data.txt");
}
}
Perhatikan blok finally yang harus menutup reader tapi juga harus menangani IOException dari close(). Ini verbose dan rawan lupa. Java 7 memperkenalkan solusi yang jauh lebih bersih.
try-with-resources: Solusi Modern untuk Resource #
try-with-resources secara otomatis menutup resource saat blok try selesai — baik normal maupun karena exception. Resource yang bisa dipakai adalah objek yang mengimplementasikan interface AutoCloseable atau Closeable.
import java.io.*;
public class ContohTryWithResources {
// ANTI-PATTERN: menutup resource manual di finally — verbose dan rawan bug
public static String bacaFileManual(String path) throws IOException {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(path));
return reader.readLine();
} finally {
if (reader != null) reader.close(); // mudah terlupakan
}
}
// BENAR: try-with-resources — reader ditutup otomatis, kode jauh lebih bersih
public static String bacaFileModern(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.readLine();
}
// reader.close() dipanggil otomatis di sini, bahkan jika exception terjadi
}
// Bisa juga membuka beberapa resource sekaligus
public static void salinFile(String sumber, String tujuan) throws IOException {
try (
BufferedReader reader = new BufferedReader(new FileReader(sumber));
BufferedWriter writer = new BufferedWriter(new FileWriter(tujuan))
) {
String baris;
while ((baris = reader.readLine()) != null) {
writer.write(baris);
writer.newLine();
}
System.out.println("File berhasil disalin.");
}
// reader dan writer keduanya ditutup otomatis, dalam urutan terbalik dari pembukaannya
}
public static void main(String[] args) {
try {
String isiPertama = bacaFileModern("input.txt");
System.out.println("Baris pertama: " + isiPertama);
salinFile("input.txt", "output.txt");
} catch (IOException e) {
System.out.println("Operasi file gagal: " + e.getMessage());
}
}
}
Selalu gunakantry-with-resourcesuntuk resource yang perlu ditutup (file, koneksi, stream). Ini lebih aman darifinallymanual karena tidak bisa lupa, dan menangani kasus edge seperti exception di dalamclose()dengan lebih baik.
Multi-catch: Menangkap Beberapa Exception #
Satu blok try bisa menghasilkan beberapa jenis exception berbeda. Ada dua cara menanganinya: catch terpisah per tipe, atau multi-catch dengan operator |.
import java.io.*;
import java.sql.*;
public class ContohMultiCatch {
public static void prosesData(String namaFile, String querySQL) {
// Tangkap setiap exception secara terpisah jika penanganannya berbeda
try {
BufferedReader reader = new BufferedReader(new FileReader(namaFile));
// ... proses file
} catch (FileNotFoundException e) {
System.out.println("File tidak ditemukan: " + namaFile);
// mungkin buat file baru atau cari di lokasi lain
} catch (IOException e) {
System.out.println("Gagal membaca file: " + e.getMessage());
// mungkin retry atau log dan skip
}
// ANTI-PATTERN: catch Exception atau Throwable terlalu luas
// catch (Exception e) { ... }
// → menangkap semua termasuk bug yang seharusnya kamu perbaiki
// BENAR: multi-catch untuk exception berbeda dengan penanganan yang SAMA
try {
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/db");
Statement stmt = conn.createStatement();
stmt.execute(querySQL);
} catch (SQLException | IllegalArgumentException e) {
// Kedua exception ini sama-sama perlu di-log dan di-notify admin
System.out.println("Error database: " + e.getMessage());
kirimNotifikasiAdmin(e);
}
}
static void kirimNotifikasiAdmin(Exception e) {
System.out.println("[ADMIN NOTIF] " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
Urutan blok catch juga penting — tangkap yang lebih spesifik dulu, baru yang lebih umum. Kalau kamu menaruh catch (Exception e) di atas catch (IOException e), kode tidak akan terkompilasi karena IOException sudah tercakup di Exception.
// ANTI-PATTERN: urutan catch salah — exception spesifik tidak pernah tercapai
try {
// kode
} catch (Exception e) { // terlalu luas, menangkap semua
// ...
} catch (IOException e) { // error: IOException sudah tertangkap di atas
// ...
}
// BENAR: spesifik dulu, umum belakangan
try {
// kode
} catch (FileNotFoundException e) { // paling spesifik
// ...
} catch (IOException e) { // lebih umum dari FileNotFoundException
// ...
} catch (Exception e) { // paling umum, sebagai safety net
// ...
}
throws: Mendelegasikan Penanganan #
Tidak semua metode harus menangani exception sendiri. Kadang lebih masuk akal untuk mendelegasikan penanganan ke pemanggil — terutama kalau pemanggil punya konteks yang lebih baik untuk memutuskan apa yang harus dilakukan.
Gunakan throws di signature metode untuk memberitahu compiler bahwa metode ini bisa melempar checked exception tertentu.
import java.io.*;
public class PemrosesLaporan {
// Metode ini tidak tahu harus diapakan jika file tidak ada
// — lebih baik lempar ke pemanggil yang tahu konteksnya
public String bacaTemplate(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
StringBuilder sb = new StringBuilder();
String baris;
while ((baris = reader.readLine()) != null) {
sb.append(baris).append("\n");
}
return sb.toString();
}
}
public String buatLaporan(String namaTemplate, String dataPengguna) throws IOException {
String template = bacaTemplate(namaTemplate); // exception menyebar ke sini
return template.replace("{{nama}}", dataPengguna);
}
}
public class Main {
public static void main(String[] args) {
PemrosesLaporan pemroses = new PemrosesLaporan();
// Pemanggil yang punya konteks: tahu harus fallback ke template default
try {
String laporan = pemroses.buatLaporan("template-kustom.txt", "Budi Santoso");
System.out.println(laporan);
} catch (FileNotFoundException e) {
System.out.println("Template tidak ditemukan, pakai template default.");
// gunakan template hardcoded sebagai fallback
} catch (IOException e) {
System.out.println("Gagal membuat laporan: " + e.getMessage());
}
}
}
sequenceDiagram
participant Main
participant PemrosesLaporan
participant bacaTemplate
Main->>PemrosesLaporan: buatLaporan("template.txt", "Budi")
PemrosesLaporan->>bacaTemplate: bacaTemplate("template.txt")
bacaTemplate-->>PemrosesLaporan: throws IOException
PemrosesLaporan-->>Main: throws IOException (propagate)
Main->>Main: catch IOException → tangani di sini
Custom Exception: Exception Spesifik Domain #
Java menyediakan banyak exception bawaan, tapi untuk domain bisnis aplikasimu, exception kustom membuat kode jauh lebih ekspresif dan mudah di-debug.
// Base exception untuk domain aplikasi — semua exception bisnis turun dari sini
public class AplikasiException extends RuntimeException {
private final String kodeError;
public AplikasiException(String kodeError, String pesan) {
super(pesan);
this.kodeError = kodeError;
}
public AplikasiException(String kodeError, String pesan, Throwable penyebab) {
super(pesan, penyebab);
this.kodeError = kodeError;
}
public String getKodeError() {
return kodeError;
}
}
// Exception spesifik per domain
public class PenggunaTidakDitemukanException extends AplikasiException {
private final long idPengguna;
public PenggunaTidakDitemukanException(long idPengguna) {
super("USR-404", "Pengguna dengan ID " + idPengguna + " tidak ditemukan.");
this.idPengguna = idPengguna;
}
public long getIdPengguna() {
return idPengguna;
}
}
public class SaldoTidakCukupException extends AplikasiException {
private final double saldoSaatIni;
private final double jumlahDiminta;
public SaldoTidakCukupException(double saldoSaatIni, double jumlahDiminta) {
super("TRX-402",
"Saldo tidak cukup. Saldo: Rp " + saldoSaatIni + ", Dibutuhkan: Rp " + jumlahDiminta);
this.saldoSaatIni = saldoSaatIni;
this.jumlahDiminta = jumlahDiminta;
}
public double getSaldoSaatIni() { return saldoSaatIni; }
public double getJumlahDiminta() { return jumlahDiminta; }
}
public class ValidasiException extends AplikasiException {
private final String namaField;
public ValidasiException(String namaField, String pesan) {
super("VAL-400", "Validasi gagal pada field '" + namaField + "': " + pesan);
this.namaField = namaField;
}
public String getNamaField() { return namaField; }
}
public class LayananTransfer {
public void transfer(long idPengirim, long idPenerima, double jumlah) {
// Validasi input — lempar ValidasiException jika tidak valid
if (jumlah <= 0) {
throw new ValidasiException("jumlah", "harus lebih dari 0");
}
if (idPengirim == idPenerima) {
throw new ValidasiException("idPenerima", "tidak boleh sama dengan pengirim");
}
AkunBank pengirim = cariAkun(idPengirim); // melempar PenggunaTidakDitemukanException jika tidak ada
AkunBank penerima = cariAkun(idPenerima);
if (pengirim.getSaldo() < jumlah) {
throw new SaldoTidakCukupException(pengirim.getSaldo(), jumlah);
}
pengirim.kurangiSaldo(jumlah);
penerima.tambahSaldo(jumlah);
System.out.println("Transfer Rp " + jumlah + " berhasil.");
}
private AkunBank cariAkun(long id) {
// Simulasi pencarian akun
if (id <= 0) throw new PenggunaTidakDitemukanException(id);
return new AkunBank(id, 1000000);
}
}
public class Main {
public static void main(String[] args) {
LayananTransfer layanan = new LayananTransfer();
try {
layanan.transfer(1L, 2L, 500000);
} catch (PenggunaTidakDitemukanException e) {
System.out.println("[" + e.getKodeError() + "] " + e.getMessage());
System.out.println("Pengguna ID: " + e.getIdPengguna());
} catch (SaldoTidakCukupException e) {
System.out.println("[" + e.getKodeError() + "] " + e.getMessage());
System.out.println("Kekurangan: Rp " + (e.getJumlahDiminta() - e.getSaldoSaatIni()));
} catch (ValidasiException e) {
System.out.println("[" + e.getKodeError() + "] " + e.getMessage());
System.out.println("Periksa field: " + e.getNamaField());
} catch (AplikasiException e) {
// Safety net untuk semua exception bisnis lain
System.out.println("[" + e.getKodeError() + "] Error tidak terduga: " + e.getMessage());
}
}
}
Chained Exception: Melacak Akar Masalah #
Saat menangkap exception dan melempar exception baru, selalu sertakan exception original sebagai cause. Ini mempertahankan seluruh jejak error sehingga kamu bisa melacak akar masalah saat debugging.
import java.io.*;
import java.sql.*;
public class RepoDatabasePengguna {
public String ambilNamaPengguna(long id) {
// ANTI-PATTERN: menelan exception original — informasi hilang
// try {
// // query database
// } catch (SQLException e) {
// throw new AplikasiException("DB-500", "Gagal ambil pengguna");
// // → stack trace asli dari SQLException hilang selamanya
// }
// BENAR: sertakan exception original sebagai cause
try {
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/db");
PreparedStatement stmt = conn.prepareStatement("SELECT nama FROM pengguna WHERE id = ?");
stmt.setLong(1, id);
ResultSet rs = stmt.executeQuery();
if (rs.next()) return rs.getString("nama");
throw new PenggunaTidakDitemukanException(id);
} catch (SQLException e) {
// Bungkus SQLException ke exception domain, tapi pertahankan cause
throw new AplikasiException("DB-500",
"Gagal mengambil data pengguna ID " + id + " dari database.", e);
// ↑ ini adalah cause
}
}
}
Dengan cause tersimpan, saat exception ini di-log, e.getCause() akan menunjukkan SQLException original beserta pesan dan stack trace-nya. Tanpa cause, kamu hanya tahu “ada error database” — tanpa tahu di query mana atau karena apa.
Pola Exception di Kode Produksi #
Di aplikasi nyata, ada beberapa pola yang konsisten digunakan untuk penanganan exception yang bersih dan maintainable.
Tangkap di layer terluar, bukan di mana-mana #
// ANTI-PATTERN: tangkap exception di setiap metode lalu lempar lagi
public String getDataPengguna(long id) {
try {
return repo.ambilNamaPengguna(id);
} catch (AplikasiException e) {
System.out.println("Error: " + e.getMessage()); // log di sini...
throw e; // ...lalu lempar lagi — duplikasi log
}
}
// BENAR: biarkan exception naik, tangkap satu kali di layer Controller/Handler
public class PenggunaController {
private LayananPengguna layanan = new LayananPengguna();
public void tampilkanPengguna(long id) {
// Exception hanya ditangkap di sini — satu tempat, satu log
try {
String nama = layanan.getNama(id);
System.out.println("Pengguna: " + nama);
} catch (PenggunaTidakDitemukanException e) {
System.out.println("404: " + e.getMessage());
} catch (AplikasiException e) {
System.out.println("500: Error internal — " + e.getKodeError());
e.printStackTrace();
}
}
}
Jangan pakai exception untuk flow control #
// ANTI-PATTERN: exception dipakai untuk logika kondisional biasa
public boolean cekPengguna(long id) {
try {
repo.ambilNamaPengguna(id);
return true;
} catch (PenggunaTidakDitemukanException e) {
return false; // exception dipakai sebagai if-else — lambat dan tidak idiomatis
}
}
// BENAR: gunakan nilai kembalian atau Optional untuk kasus yang memang bisa tidak ada
public boolean cekPengguna(long id) {
return repo.existsById(id); // method yang mengembalikan boolean
}
// Atau gunakan Optional<T>
import java.util.Optional;
public Optional<String> cariNamaPengguna(long id) {
try {
return Optional.of(repo.ambilNamaPengguna(id));
} catch (PenggunaTidakDitemukanException e) {
return Optional.empty();
}
}
Log dengan konteks, bukan hanya pesan #
// ANTI-PATTERN: log tanpa konteks — sulit di-debug di production
catch (SQLException e) {
System.out.println("Database error"); // tidak berguna
}
// BENAR: log dengan konteks yang cukup untuk reproduce masalah
catch (SQLException e) {
System.out.printf(
"Gagal query database [tabel=pengguna, operasi=SELECT, id=%d]: %s%n",
idPengguna, e.getMessage()
);
e.printStackTrace(); // atau gunakan logger seperti SLF4J/Logback di production
}
Kapan Menggunakan Checked vs Unchecked Exception #
Gunakan CHECKED EXCEPTION jika:
✓ Kondisi error bisa diantisipasi dan pemanggil bisa melakukan sesuatu
(file tidak ada → bisa minta pengguna pilih file lain)
✓ Kondisi error berasal dari faktor eksternal (I/O, jaringan, database)
✓ Kamu ingin compiler memaksa pemanggil menangani error tersebut
Gunakan UNCHECKED EXCEPTION (RuntimeException) jika:
✓ Error adalah akibat bug dalam kode — seharusnya dicegah, bukan ditangkap
(null pointer, indeks negatif, argumen tidak valid)
✓ Melempar checked exception akan memaksa banyak metode perantara
mendeklarasikan throws yang tidak relevan bagi mereka
✓ Kamu membangun exception domain bisnis yang akan ditangkap di layer terluar
JANGAN:
✗ Tangkap Exception atau Throwable secara generik kecuali di error handler global
✗ Tangkap exception lalu abaikan (catch kosong atau hanya print)
✗ Gunakan exception sebagai mekanisme return value atau flow control
✗ Lempar exception baru tanpa menyertakan cause dari exception original
Ringkasan #
- Exception adalah objek yang mewarisi
Throwable. Hirarki utama:Error(jangan tangkap),checked Exception(wajib tangani),RuntimeException/ unchecked (sinyal bug).try-catch-finallyadalah blok dasar.finallyselalu dijalankan — cocok untuk cleanup resource, tapi lebih baik pakaitry-with-resources.try-with-resources(Java 7+) menutup resource otomatis. Gunakan selalu untuk file, koneksi, dan stream — tidak bisa lupa, tidak rawan bug.- Multi-catch dengan
|menyederhanakan kode saat beberapa exception butuh penanganan yang sama. Tangkap yang paling spesifik dulu, baru yang lebih umum.throwsmendelegasikan penanganan ke pemanggil yang punya konteks lebih baik. Pakai ketika metode tidak tahu apa yang harus dilakukan saat error terjadi.- Custom exception membuat kode lebih ekspresif. Buat hirarki exception per domain (
AplikasiExceptionsebagai base), sertakan data yang relevan (kode error, field yang gagal).- Chained exception mempertahankan akar masalah. Selalu sertakan cause (
throw new AppException("msg", e)) saat membungkus exception — jangan telan informasi aslinya.- Tangkap di layer terluar — biarkan exception naik secara alami, tangkap satu kali di controller atau handler. Hindari menangkap lalu melempar ulang yang hanya menduplikasi log.