Java: Strategi Mengendalikan Kegagalan pada Code
Mon. Jun 9th, 2025 02:14 PM10 mins read
Java: Strategi Mengendalikan Kegagalan pada Code
Source: Ideogram - error handling

Di Java terdapat class Throwable yang berfungsi untuk menghentikan eksekusi code ketika terjadi kegagalan. Throwable terbagi jadi 2, yaitu Error dan Exception. Exception sendiri dipecah lagi jadi 2 jenis Exception, yaitu Unchecked Exception yang merupakan turunan Runtime Exception, dan Checked Exception yang merupakan turunan Exception selain Runtime Exception. Semuanya memiliki fungsi dan behavior yang berbeda untuk masing-masing kasus.

Throwable

Ini adalah hierarki tertinggi untuk handling Error dan Exception. Semua turunan dari class ini bisa menghentikan eksekusi code saat objeknya dibuat bersama keyword throw dan dapat dihandle dengan try catch block. Class ini sendiri fungsinya hanyalah sebagai template untuk turunannya saja. Untuk kondisi spesifiknya umumnya dikendalikan oleh masing-masing turunan.

Error

Error adalah turunan dari Throwable yang mengindikasikan bahwa terjadi kegagalan pada internal system seperti Stack Overflow, Out of Memory, dan internal error lainnya. Error sebaiknya tidak dihandle menggunakan try catch block, karena ini fungsinya memberi tahu kita bahwa telah terjadi masalah serius pada system yang harus ditangani. Secara umum sebaiknya menggunakan Error pada logic bisnis dihindari, kecuali saat develop tools terkait internal system.

Exception

Exception adalah turunan dari Throwable yang mengindikasikan bahwa terjadi kegagalan pada aplikasi. Berbeda dengan Error, pada Exception kita dianjurkan melakukan handling lewat try catch block maupun lewat conditional logic sebelum eksekusi, karena ini fungsinya memang untuk mengontrol kegagalan secara bisnis. Exception dipecah lagi menjadi 2, yaitu Unchecked Exception dan Checked Exception.

Checked Exception

Yang termasuk bagian dari Checked Exception adalah semua class Exception selain turunan dari RuntimeException. Saat membuat method yang melempar Checked Exception, kita wajib menulis keyword throws beserta class Exception yang dilempar pada method signature.

Copy
public void readFile() throws FileNotFoundException { //must declare throws on method signature
	File file = new File("missing.txt");
	if(!file.canRead()){
		throw new FileNotFoundException("can not read file!");
	}
}

Saat menggunakan method yang memiliki signature Checked Exception, kita wajib menghandle Exception tersebut menggunakan keyword try catch block. Jika tidak dilakukan maka code akan gagal di-compile. Fungsi dari Checked Exception adalah untuk mewajibkan kita melakukan recovery atau menghandle logic apa yang akan dilakukan jika terjadi kegagalan. Misalnya saat membaca file biasanya ada kemungkinan gagal yang bisa disebabkan oleh beberapa hal seperti file corrupt, tiba-tiba terhapus, dan sebagainya. Di Java, Checked Exception yang digunakan untuk hal ini adalah IOException. Seperti pada code berikut:

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) {
	System.out.println(downloadFromServer("hello.txt")); //recovered by download a new file from server without crash
}

Pada code di atas kita akan membaca text file “hello.txt”. Di Java, method lines() tersebut memiliki IOException pada method signature, sehingga kita yang menggunakan method tersebut diwajibkan melakukan handling Exception. Untuk itu pada contoh kasus ini kita ingin menghandle bahwa jika file tersebut gagal dibaca, maka akan dilakukan download file baru dari server. Ekspektasinya di sini adalah jika terjadi Checked Exception maka tidak langsung gagal, melainkan ada alternatif lain yang dieksekusi. Best practice-nya, Checked Exception hanya digunakan untuk hal-hal yang wajib di-recover saat terjadi kegagalan, seperti membaca file, menggunakan multi-threading, dan lain-lain.

Unchecked Exception

Class Exception memiliki turunan spesial, yaitu RuntimeException di mana khusus RuntimeException beserta seluruh turunannya boleh untuk tidak dihandle secara eksplisit dan ga akan bikin gagal compile jika tidak dihandle. Kita tidak wajib menulis Exception pada method signature, tapi jika terdapat Unchecked Exception pada method tersebut tetap dianjurkan untuk ditulis pada method signature sebagai dokumentasi.

Copy
private String getFormat(double value){ //no need to declare exception on method signature, but encouraged to do it
	if(Double.isNaN(value)){
		throw new IllegalArgumentException("illegal value");
	}
	return String.format("%.2f", value);
}

Kita tidak dianjurkan untuk menghandle Unchecked Exception lewat try catch block tanpa alasan kuat karena fungsinya emang untuk menghentikan flow aplikasi saat terjadi kegagalan. Kalaupun perlu dihandle, maka sebaiknya dilakukan lewat conditional logic sebelum eksekusi. Contoh yang paling sering terlihat adalah NullPointerException.

Copy
LocalDate date = getDate(); //nullable date
if(date != null){ //handle it with conditional logic before execution
	System.out.println(date.getYear());
}

LocalDate date2 = getDate(); //nullable date
System.out.println(date2.getYear()); //apps will be crashed if date2 is null😱

Ekspektasinya di sini adalah jika terjadi Unchecked Exception maka flow aplikasinya akan berhenti dan harus diinvestigasi logic bisnisnya. Best practicenya, Unchecked Exception digunakan untuk kasus di mana kita memang ingin flow aplikasinya berhenti saat terjadi kegagalan seperti saat melakukan validasi. Jika inputnya salah tentu ekspektasinya flow harus berhenti.

Try Catch Finally Block

Copy
private static String readMyFile(){
	Path path = Path.of("hello.txt");
	Stream<String> lines = null;
	try {
		System.out.println("try");
		lines = Files.lines(path);
		return lines.collect(Collectors.joining("\n"));
	} catch (IOException e) {
		System.out.println("catch");
		return downloadFromServer("hello.txt");
	} finally{
		if(lines != null){
			System.out.println("close");
			lines.close(); //executes close() manually
		}
	}
}

Di Java kita bisa menghandle Throwable menggunakan try, catch, dan finally block. Blok try berisi bagian eksekusi. Blok catch berisi bagian untuk handling jika terjadi kegagalan pada blok try. Blok finally berisi bagian yang akan selalu dieksekusi setelah blok try berhasil dieksekusi maupun setelah blok catch dieksekusi saat terjadi kegagalan, biasanya ini untuk melakukan pembersihan resources seperti menutup stream, koneksi, atau sejenisnya.

Try With Resources

Copy
public static class MyStream implements AutoCloseable{
	public String readLines() throws IOException{
		//read lines code
		if(true) throw new IOException("test throw");
		return "read";
	}

	@Override
	public void close(){
		//cleaning up code
		System.out.println("close");
	}
}

private static String readMyFile(){
	try(MyStream myStream = new MyStream()){ //will execute close() automatically
		System.out.println("try");
		return myStream.readLines();
	} catch(IOException e){
		System.out.println("catch");
		return downloadFromServer("hello.txt");
	}
}

Asalkan objek yang perlu dibersihkan mengimplementasi interface AutoCloseable, maka pembersihan resources untuk menutup stream, koneksi, atau sejenisnya bisa dilakukan dengan cara yang lebih simple, yaitu menggunakan try with resources. Objek yang butuh dibersihkan tinggal dipindahkan ke dalam kurung blok try(), nanti setelah eksekusi blok try berhasil ataupun sebelum blok catch dieksekusi saat terjadi kegagalan, method close() pada objek akan otomatis dieksekusi tanpa perlu bikin blok finally. Di sini code jadi lebih bersih dibandingkan menggunakan blok finally. Bedanya lagi, di sini method close() dieksekusi sebelum blok catch saat terjadi kegagalan. Sedangkan saat menggunakan blok finally, method close() dieksekusi setelah blok catch dieksekusi.

Common Bad Practices

Beberapa pendekatan umum yang dianggap bad practice saat menggunakan Throwable adalah sebagai berikut:

Catch Error/Throwables

Copy
try {
    // some codes
} catch (Throwable t) { //catches fatal problems but execution continues🙀
    t.printStackTrace();
}

Objek Error dan Throwable secara umum tidak untuk dihandle karena emang fungsinya ga recoverable secara bisnis. Ini dapat menyembunyikan kegagalan yang cukup serius seperti memori penuh dan eksekusi aplikasinya terus berlanjut. Dengan error sebaiknya flow harus berhenti agar kita dapat mendeteksi kegagalan system dan menanganinya dengan melakukan upgrade system atau melakukan penyelidikan di bagian mana terjadi kebocoran memori.

Catch General Exception

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (Exception e) { //also catches RuntimeException🙀
	System.out.println(downloadFromServer("hello.txt"));
}

Jika Checked Exception ditangkap pake Exception, maka Unchecked Exception juga akan ikut tertangkap karena RuntimeException juga merupakan turunan dari Exception. Bugs seperti NullPointerException akan ikut terhandle sehingga flow aplikasinyas tetap berjalan. Jika terdapat Checked Exception, maka sebaiknya Exception tersebut ditangkap secara spesifik seperti berikut:

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) { //only catches IOException👍
	System.out.println(downloadFromServer("hello.txt"));
}

Allow Predicted Exception

Copy
Book book = getBook(); //nullable
String bookTitle = book.getTitle(); //risk of NullPointerException🫣

Membiarkan Exception yang bisa diprediksi terjadi merupakan bad practice. Contohnya saat kita membiarkan NullPointerException terjadi ketika menggunakan input yang nullable. Ini akan bikin penggunaan memori ga efektif karena itu akan menghasilkan objek NullPointerException beserta objek stack traces dan detail objek lain di dalamnya. Jika ini dicegah lebih awal sebelum terjadi Exception, tentu lebih efisien karena aplikasi ga perlu bikin objek Exception sama sekali.

Copy
Book book = getBook(); //nullable
String bookTitle = book.getTitle() != null ? book.getTitle : ""; //NullPointerException prevented👍

Simple Control Flow

Copy
private double calculate(double v1, double v2){
	try {
		return v1 / v2; //throws ArithmeticException when divided by 0
	} catch (ArithmeticException e) { //memory inefficient and verbose👎
		return 0D;
	}
}

Memanfaatkan Exception untuk flow yang sederhana juga merupakan bad practice karena code jadi verbose dan secara memori ga efisien. Contohnya saat melakukan pembagian dengan bilangan 0 yang dapat menghasilkan ArithmeticException kita ingin mengembalikan nilai 0 by default. Ini bisa disederhanakan dengan melakukan conditional logic seperti berikut:

Copy
private double calculate(double v1, double v2){
	if(v2 == 0D){ //much simpler and memory efficient😎
		return 0D;
	}
	return v1 / v2;
}

Catch Unchecked Exception Without Valid Reason

Copy
try {
	Book book = getBook(); //nullable
	String bookTitle = book.getTitle(); 
} catch (NullPointerException e) { //no valid reason for doing this👎
	e.printStackTrace();
}

Walaupun secara rules Unchecked Exception bisa ditangkap, tapi ini merupakan bad practice jika ditangkap tanpa alasan kuat karena ga sesuai fungsinya. Akan tetapi ini boleh dilakukan untuk alasan tertentu. Misalnya saat bikin utility agar input yang salah format bisa dihandle menggunakan default value. Ini bisa diwajarkan.

Copy
public static int toInt(String str, int defaultValue) {
	if(str == null || str.isEmpty()){
		return defaultValue;
	}
	try {
		return Integer.parseInt(str);
	} catch (RuntimeException e) { //acceptable🤷
		return defaultValue;
	}
}

Unchecked Exception juga boleh ditangkap oleh top-level layer atau top-level method yang memanggilnya. Ini biasanya dilakukan lewat AOP atau framework seperti ControllerAdvice pada Spring untuk global handling.

Copy
@ControllerAdvice
public class AdviceController{

	@ExceptionHandler(CustomRuntimeException.class)
	public String handleCustomRuntimeException(CustomRuntimeException e) {
		//handle all CustomRuntimeException globally here
	}

}

Overuse Checked Exception

Copy
public static class PaymentException extends Exception { }

public void processOrder() throws PaymentException { //overuse Checked Exception propagation👎
	//some codes
	processTransaction();
}

public void processTransaction() throws PaymentException {
	//some codes
	processPayment();
}

public void processPayment() throws PaymentException {
	//some codes
	if(someCondition){
		throw new PaymentException();
	}
}

Menggunakan Chekced Exception pada kasus yang tidak tepat akan membuat code jadi susah dimaintain. Apalagi jika dihandle dengan menulis Exception pada method signature agar dihandle oleh method yang memanggilnya, lalu dihandle lagi oleh stack method di atasnya dengan cara yang sama, begitu seterusnya. Ini akan membuat polusi Exception pada method signature. Sebelum ada alasan yang mewajibkan orang yang menggunakan method tersebut untuk melakukan recovery, maka by default jangan bikin Exception menggunakan Checked Exception. Sebagian besar kasus pada logic aplikasi cocoknya menggunakan Unchecked Exception karena by default secara bisnis saat terjadi kegagalan kita ingin flow-nya berhenti, kecuali kasus-kasus spesifik yang memang harus di-recover.

Copy
public static class PaymentException extends RuntimeException { }

public void processOrder() { //cleaner method signature👍
	//some codes
	processTransaction();
}

public void processTransaction() {
	//some codes
	processPayment();
}

public void processPayment() {
	//some codes
	if(someCondition){
		throw new PaymentException();
	}
}

Silent Exception

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) {
	// bad practice for doing nothing🚫
}

Seringkali saat bingung menggunakan method yang memiliki Checked Exception pada method signature orang-orang hanya melakukan catch tanpa melakukan apa-apa. Ini bad practice karena Checked Exception itu artinya ada hal yang wajib dihandle saat terjadi kegagalan. Jika dibiarkan tanpa melakukan sesuatu, artinya kita membiarkan kesalahan terjadi tanpa ada recovery.

Log Handling

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) {
	log.error("someting wrong", e); //only logging, bad practice unless there is no other options🤷
}

Log handling gini ga beda jauh dengan Silent Exception, hanya bedanya di sini ada log yang ditulis. Keduanya sama-sama ga ngapa-ngapain. Secara umum ini bad practice karena sesuai tujuan awal, Checked Exception itu digunakan untuk kasus yang memang ingin di-recover saat gagal. Ini hanya dilakukan sebagai langkah terakhir pada kasus tertentu di mana emang ga ada alternatif lain untuk melakukan recovery dan ga ada efek samping yang penting saat ini terjadi sehingga flow tetap berlanjut.

Wrapping Exception

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) {
	throw new MyAppsRuntimeException("failed to read files", e); //not a good practice but still acceptable as last resort🤷
}

Sama seperti Log Handling, ini juga bad practice karena ga sesuai tujuan dari Checked Exception, tapi boleh dilakukan tergantung kasus sebagai langkah terakhir di saat emang ga ada recovery lain yang bisa dilakukan. Misalnya saat membaca file terjadi interupsi dan kita ingin flow bisnis berhenti tanpa ada alternatif lain, maka kita boleh membungkusnya menggunakan Unchecked Exception. Langkah ini sedikit lebih baik daripada hanya melakukan logging tanpa ada tindak lanjut apa-apa karena dengan begini setidaknya user sadar bahwa ada kegagalan pada flow bisnis. Solusi lainnya adalah menggunakan @SneakyThrows dari plugin Lombok agar method yang memiliki Checked Exception yang ga ada recovery bisa diperlakukan seperti Unchecked Exception.

Throw New Exception

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) {
	throw new MyAppsRuntimeException("failed to read files"); //no original Exception wrapped⛔
}

Seperti pada kasus sebelumnya, jika ga ada recovery yang bisa dilakukan maka sebagai langkah terakhir boleh-boleh saja melakukan throwing dengan Unchecked Exception. Namun, jika Exception yang ditangkap tidak dibungkus pada constructor Exception yang baru dibuat maka ini juga bad practice karena kita akan kehilangan stack traces yang ditangkap oleh Exception awal. Ini akan menyulitkan debugging saat mencari tahu root cause kegagalannya. Oleh karena itu, jika langkah terakhirnya adalah throw new Exception, maka pastikan Exception yang ditangkap dibungkus ke dalam objek Exception yang baru seperti poin sebelumnya.

Re-throw Current Exception

Copy
Path path = Path.of("hello.txt");
try (Stream<String> lines = Files.lines(path)) {
	lines.forEach(System.out::println);
} catch (IOException e) {
	throw e; //pointless👀
}

Kita bisa menangkap Exception lalu melakukan throw Exception yang sama tanpa bikin objek Exception baru. Ini bad practice karena hanya menambah kompleksitas tanpa menambah manfaat apa pun.

Verdict

Pada Java terdapat class Throwable untuk handling flow aplikasi saat terjadi kegagalan. Error digunakan untuk handle kegagalan yang disebabkan oleh internal system. Exception digunakan untuk handle kegagalan yang disebabkan oleh aplikasi. Exception dibagi menjadi 2, Checked Exception (turunan class Exception selain RuntimeException) dan Unchecked Exception (turunan class RuntimeException). Checked Exception digunakan untuk kasus di mana diperlukan recovery saat terjadi kegagalan. Unchecked Exception digunakan untuk kasus di mana flow aplikasi berhenti tanpa perlu recovery. Unchecked Exception lebih sering digunakan pada sebagian besar logic bisnis daripada Checked Exception karena lebih simple penggunaannya dan secara umum memang kita ingin flow bisnis berhenti saat terjadi kegagalan. Jika diperlukan untuk throw Exception baru, maka pastikan Exception yang ditangkap dibungkus pada Exception baru agar ga kehilangan stack traces dari Exception sebelumnya.

© 2025 · Ferry Suhandri