Tampilan
Kenapa penting
Di bab sebelumnya kamu melihat readTemplate wajib menulis throws IOException, sedangkan checkStock bebas melempar IllegalStateException tanpa mengumumkan apa pun. Perbedaan itu — checked versus unchecked — menentukan bentuk API yang kamu rancang: apa yang wajib ditangani pemanggil, exception apa yang dilempar service, dan bagaimana error sampai ke klien API sebagai status HTTP yang tepat. Developer backend yang memilih jenis exception dengan sembarangan akan menghasilkan kode yang penuh try/catch tak berguna, atau sebaliknya, error yang hilang tanpa jejak.
Konsep inti
Aturan compiler: "catch or declare"
- Checked exception — turunan
Exceptionyang bukan turunanRuntimeException(mis.IOException). Compiler mewajibkan setiap pemanggil menangkap exception itu atau mengumumkannya denganthrows. - Unchecked exception — turunan
RuntimeException(mis.IllegalArgumentException,NullPointerException) dan jugaError. Compiler tidak mewajibkan apa pun.
java
import java.nio.file.Files;
import java.nio.file.Path;
public class Unreported {
static String readTemplate(String path) {
return Files.readString(Path.of(path));
}
public static void main(String[] args) {
System.out.println(readTemplate("welcome.txt"));
}
}Program di atas tidak bisa dikompilasi:
text
Unreported.java:6: error: unreported exception IOException; must be caught or declared to be thrown
return Files.readString(Path.of(path));
^
1 errorPilihannya: bungkus dengan try/catch bila method ini memang bisa menangani kegagalan, atau tambahkan throws IOException agar tanggung jawab pindah ke pemanggil.
Kapan checked, kapan unchecked?
| Situasi | Jenis | Contoh |
|---|---|---|
| Pemanggil memberi argumen yang melanggar kontrak (bug) | unchecked | IllegalArgumentException |
| Method dipanggil saat state object tidak mengizinkan | unchecked | IllegalStateException |
| Aturan bisnis dilanggar (stok habis, produk tidak ada) | unchecked (custom) | OutOfStockException |
| Gangguan dari luar yang wajar dan bisa dipulihkan pemanggil | checked | IOException, gateway pembayaran timeout |
| Masalah serius di JVM | Error — jangan dilempar/ditangkap | OutOfMemoryError |
Dalam praktik modern, banyak tim dan framework condong ke unchecked. Spring, misalnya, menerjemahkan SQLException (checked) menjadi turunan DataAccessException (unchecked). Alasannya: checked exception memaksa setiap lapisan di tengah menulis throws atau try/catch walaupun mereka tidak bisa berbuat apa-apa, dan error itu toh akhirnya ditangani di satu tempat terpusat. Checked exception tetap kamu temui di API JDK (I/O, jaringan) — tugasmu menanganinya di batas sistem, lalu meneruskannya sebagai exception yang bermakna bagi domainmu.
Membuat custom exception
Exception bawaan seperti IllegalStateException terlalu umum untuk aturan bisnis. Custom exception membuat kode pemanggil bisa menangkap kasus yang spesifik dan membawa data tambahan untuk penanganannya.
java
class OutOfStockException extends RuntimeException {
private final String sku;
private final int requested;
private final int available;
OutOfStockException(String sku, int requested, int available) {
super("Stok " + sku + " kurang: diminta " + requested + ", tersedia " + available);
this.sku = sku;
this.requested = requested;
this.available = available;
}
String sku() {
return sku;
}
int shortage() {
return requested - available;
}
}
class Warehouse {
private int stock = 4;
void reserve(String sku, int quantity) {
if (quantity < 1) {
throw new IllegalArgumentException("quantity harus >= 1: " + quantity);
}
if (quantity > stock) {
throw new OutOfStockException(sku, quantity, stock);
}
stock -= quantity;
}
}
public class CustomExceptions {
public static void main(String[] args) {
Warehouse warehouse = new Warehouse();
warehouse.reserve("SKU-001", 3);
try {
warehouse.reserve("SKU-001", 5);
} catch (OutOfStockException e) {
System.out.println(e.getMessage());
System.out.println("Tawarkan pre-order untuk " + e.shortage() + " unit " + e.sku());
}
}
}Output:
text
Stok SKU-001 kurang: diminta 5, tersedia 1
Tawarkan pre-order untuk 4 unit SKU-001Resep custom exception:
- Turunkan dari
RuntimeException(unchecked) atauException(checked) sesuai tabel di atas. - Nama diakhiri
Exceptiondan menjelaskan apa yang salah:OutOfStockException, bukanStockError2. - Panggil
super(message)dengan pesan yang memuat konteks (SKU, jumlah) — pesan ini yang muncul di log. - Sediakan constructor
(String message, Throwable cause)bila exception ini akan membungkus exception lain. - Field tambahan (SKU, kode error) dibuat
finaldan hanya punya getter.
Exception chaining: membungkus tanpa kehilangan penyebab
Lapisan atas tidak perlu tahu detail teknis lapisan bawah. Service invoice tidak seharusnya bocor IOException ke controller — tetapi penyebab teknisnya wajib dipertahankan untuk debugging. Caranya: tangkap exception level rendah, lalu lempar exception yang bermakna bagi domain dengan exception asli sebagai cause.
java
import java.io.IOException;
class StorageException extends RuntimeException {
StorageException(String message, Throwable cause) {
super(message, cause);
}
}
class FileInvoiceStore {
void write(String invoiceId) throws IOException {
throw new IOException("Disk penuh"); // simulasi kegagalan I/O
}
}
class InvoiceService {
private final FileInvoiceStore store = new FileInvoiceStore();
void archive(String invoiceId) {
try {
store.write(invoiceId);
} catch (IOException e) {
throw new StorageException("Gagal mengarsipkan invoice " + invoiceId, e);
}
}
}
public class Chaining {
public static void main(String[] args) {
try {
new InvoiceService().archive("INV-42");
} catch (StorageException e) {
System.out.println(e.getMessage());
System.out.println("Penyebab: " + e.getCause());
}
}
}Output:
text
Gagal mengarsipkan invoice INV-42
Penyebab: java.io.IOException: Disk penuhBila exception ini tidak ditangkap, stack trace-nya akan memuat bagian Caused by: java.io.IOException: Disk penuh — persis seperti yang kamu pelajari di bab sebelumnya. Tanpa argumen e, informasi "disk penuh" hilang selamanya.
Kapan melempar exception
- Argumen tidak valid →
IllegalArgumentException(atau custom turunannya) di awal method. - State tidak mengizinkan operasi →
IllegalStateException, misalnya membayar order yang sudah lunas:
java
class Order {
private final String id;
private boolean paid;
Order(String id) {
this.id = id;
}
void pay() {
if (paid) {
throw new IllegalStateException("Order " + id + " sudah dibayar");
}
paid = true;
System.out.println("Order " + id + " dibayar");
}
}
public class IllegalState {
public static void main(String[] args) {
Order order = new Order("INV-5");
order.pay();
order.pay();
}
}text
Order INV-5 dibayar
Exception in thread "main" java.lang.IllegalStateException: Order INV-5 sudah dibayar
at Order.pay(IllegalState.java:11)
at IllegalState.main(IllegalState.java:22)- Jangan memakai exception untuk alur normal. Exception mahal (membuat stack trace) dan membuat alur sulit dibaca. Kalau pertanyaannya wajar dan sering ditanyakan, sediakan method yang menjawabnya:
java
// Potongan — JANGAN: exception sebagai if-else
try {
catalog.findBySku(sku);
return true;
} catch (ProductNotFoundException e) {
return false;
}
// LEBIH BAIK: method yang memang menjawab pertanyaan itu
return catalog.exists(sku);Contoh di dunia backend
Alur checkout menggabungkan semuanya: validasi argumen, aturan bisnis sebagai custom exception, gateway pembayaran yang melempar checked exception lalu dibungkus menjadi exception domain, dan satu handler terpusat yang memetakan setiap exception ke status HTTP. Di Spring Boot nanti, peran handle dan toHttpStatus dipegang oleh @ControllerAdvice.
java
import java.util.HashMap;
import java.util.Map;
class ProductNotFoundException extends RuntimeException {
ProductNotFoundException(String sku) {
super("Produk tidak ditemukan: " + sku);
}
}
class OutOfStockException extends RuntimeException {
OutOfStockException(String sku, int requested, int available) {
super("Stok " + sku + " kurang: diminta " + requested + ", tersedia " + available);
}
}
class PaymentGatewayException extends Exception { // checked: gangguan dari luar
PaymentGatewayException(String message) {
super(message);
}
}
class PaymentFailedException extends RuntimeException { // unchecked: versi domain
PaymentFailedException(String message, Throwable cause) {
super(message, cause);
}
}
class PaymentGateway {
void charge(String orderId, long amount) throws PaymentGatewayException {
if (amount > 500_000) {
throw new PaymentGatewayException("timeout dari bank"); // simulasi
}
}
}
class CheckoutService {
private final Map<String, Integer> stock = new HashMap<>(Map.of("KOPI", 10, "TEH", 2));
private final Map<String, Long> prices = Map.of("KOPI", 85_000L, "TEH", 40_000L);
private final PaymentGateway gateway = new PaymentGateway();
String checkout(String orderId, String sku, int quantity) {
if (quantity < 1) {
throw new IllegalArgumentException("Jumlah harus >= 1: " + quantity);
}
Integer available = stock.get(sku);
if (available == null) {
throw new ProductNotFoundException(sku);
}
if (quantity > available) {
throw new OutOfStockException(sku, quantity, available);
}
long amount = prices.get(sku) * quantity;
try {
gateway.charge(orderId, amount);
} catch (PaymentGatewayException e) {
throw new PaymentFailedException("Pembayaran " + orderId + " gagal", e);
}
stock.put(sku, available - quantity);
return orderId + " lunas Rp" + amount;
}
}
public class CheckoutApi {
static int toHttpStatus(RuntimeException e) {
return switch (e) {
case IllegalArgumentException _ -> 400;
case ProductNotFoundException _ -> 404;
case OutOfStockException _ -> 409;
case PaymentFailedException _ -> 502;
default -> 500;
};
}
static String handle(CheckoutService service, String orderId, String sku, int quantity) {
try {
return "200 " + service.checkout(orderId, sku, quantity);
} catch (RuntimeException e) { // satu-satunya tempat menangkap secara luas
return toHttpStatus(e) + " " + e.getMessage();
}
}
public static void main(String[] args) {
CheckoutService service = new CheckoutService();
System.out.println(handle(service, "INV-1", "KOPI", 2));
System.out.println(handle(service, "INV-2", "KOPI", 0));
System.out.println(handle(service, "INV-3", "SUSU", 1));
System.out.println(handle(service, "INV-4", "TEH", 5));
System.out.println(handle(service, "INV-5", "KOPI", 7));
}
}Output:
text
200 INV-1 lunas Rp170000
400 Jumlah harus >= 1: 0
404 Produk tidak ditemukan: SUSU
409 Stok TEH kurang: diminta 5, tersedia 2
502 Pembayaran INV-5 gagalPerhatikan pembagian tugasnya. CheckoutService hanya melempar exception yang bermakna dan tidak tahu apa pun tentang HTTP. Checked PaymentGatewayException ditangani tepat di batas tempat gateway dipanggil, lalu dibungkus dengan cause-nya. Handler terpusat adalah satu-satunya tempat yang menangkap RuntimeException secara luas — dan default -> 500 menangkap bug yang tidak terduga. Switch di toHttpStatus memakai type pattern dengan _ dari bab Sealed & Pattern Matching; karena RuntimeException bukan sealed, default tetap wajib. Di production, pesan untuk status 500 sebaiknya generik agar detail internal tidak bocor ke klien.
Pertanyaan interview
1. Apa beda checked dan unchecked exception?
Checked exception adalah turunan Exception selain RuntimeException; compiler mewajibkan pemanggil menangkapnya atau mengumumkannya dengan throws. Unchecked exception adalah turunan RuntimeException (dan Error) yang tidak diperiksa compiler. Checked cocok untuk gangguan dari luar yang wajar dipulihkan; unchecked untuk bug pemanggil, state ilegal, dan pelanggaran aturan bisnis.
2. Bagaimana kamu merancang custom exception?
Turunkan dari RuntimeException (umumnya) atau Exception, beri nama yang menjelaskan masalahnya, sediakan constructor dengan pesan dan constructor dengan cause, lalu tambahkan field final untuk data yang dibutuhkan penanganannya (mis. SKU, kode error). Pesan harus memuat konteks, dan exception asli selalu dioper sebagai cause saat membungkus.
3. Kenapa Spring dan banyak framework modern condong ke unchecked exception?
Checked exception memaksa setiap lapisan di tengah menulis throws atau try/catch walaupun mereka tidak bisa memulihkan apa-apa, sehingga kode penuh boilerplate dan API saling terikat. Unchecked exception dibiarkan merambat ke penanganan terpusat — di Spring lewat @ControllerAdvice/@ExceptionHandler — yang memetakannya ke respons HTTP. Contohnya, Spring menerjemahkan SQLException menjadi DataAccessException yang unchecked.
Jebakan umum
- Membungkus tanpa
cause.throw new StorageException("Gagal")di dalamcatchmembuang akar masalah — selalu opere. throws Exceptiondi signature. Pemanggil jadi tidak tahu apa yang bisa salah dan terpaksa menangkap segalanya. Umumkan jenis yang spesifik.- Checked exception untuk bug pemanggil. Argumen tidak valid bukan hal yang bisa "dipulihkan"; pakai unchecked.
- Exception sebagai alur normal (mis. menangkap
ProductNotFoundExceptionuntuk menjawab "ada atau tidak"). Sediakan method sepertiexists. - Membocorkan pesan internal ke klien API. Stack trace, query SQL, atau path file di respons 500 adalah celah keamanan; kirim pesan generik dan simpan detailnya di log.
- Pesan tanpa konteks seperti
"Error". Sertakan ID, nilai yang ditolak, dan batasnya.
Ringkasan
- Checked exception (turunan
Exceptionnon-RuntimeException) wajib ditangkap atau diumumkan; unchecked tidak. - Unchecked untuk argumen tidak valid, state ilegal, dan aturan bisnis; checked untuk gangguan luar yang wajar dipulihkan.
- Custom exception: nama jelas, pesan berkonteks, constructor dengan
cause, fieldfinaluntuk data tambahan. - Saat membungkus exception level rendah, selalu oper exception asli sebagai
cause. IllegalArgumentExceptionuntuk argumen salah,IllegalStateExceptionuntuk state yang tidak mengizinkan.- Petakan exception domain ke status HTTP di satu tempat terpusat; jangan pakai exception untuk alur normal.