Bab lalu kita fokus pada jalur "semua berjalan lancar" — suspend fun yang selalu mengembalikan hasil sukses. Tapi kode backend nyata harus jujur soal kemungkinan gagal: quantity yang dikirim client bukan angka, item yang dicari tidak ada di stok, saldo tidak cukup. Bab penutup Fase 0 ini membahas dua cara Kotlin merepresentasikan kegagalan secara idiomatis — lewat kotlin.Result dan sealed class — dan kapan masing-masing lebih tepat dipakai, lewat test asli ErrorHandlingBasicsTest.kt.
Exception Saja Tidak Selalu Cukup
Cara paling umum menangani error di banyak bahasa OOP adalah throw exception, ditangkap dengan try/catch di pemanggil. Ini masuk akal untuk kondisi yang benar-benar tidak terduga — koneksi database putus di tengah query, bug di kode yang seharusnya tidak pernah terjadi. Tapi untuk kegagalan yang sebenarnya bagian normal dari alur bisnis — misalnya input user yang tidak valid, atau stok yang habis — melempar exception punya kelemahan: signature fungsi tidak bilang apa-apa soal kemungkinan gagalnya. Pemanggil baru tahu fungsi itu bisa melempar exception dengan membaca dokumentasi (kalau ada) atau body implementasinya, bukan dari tipe kembaliannya. Gampang lupa try/catch, dan compiler tidak akan menegurmu.
Kotlin menawarkan dua pola yang membuat kemungkinan gagal itu terlihat di signature fungsi — konsisten dengan filosofi null safety yang kita bahas di bab pertama: kalau sesuatu bisa tidak ada atau bisa gagal, tipe-nya harus bilang begitu secara eksplisit.
Pendekatan 1: kotlin.Result<T> untuk Expected Failure
kotlin.Result<T> adalah tipe pembungkus generik dari standard library Kotlin: sebuah nilai yang merepresentasikan sukses membawa data atau gagal membawa exception, tanpa exception itu benar-benar dilempar. Contoh di test file ini adalah parsing quantity dari input string:
// Pendekatan 1: kotlin.Result - cocok untuk kegagalan yang memang bagian normal dari alur (expected failure).
private fun parseQuantity(raw: String): Result<Int> =
raw.toIntOrNull()?.let { Result.success(it) } ?: Result.failure(IllegalArgumentException("'$raw' bukan angka"))
Perhatikan signature-nya: parseQuantity(raw: String): Result<Int> — cuma dengan membaca tipe kembaliannya, pemanggil sudah tahu fungsi ini bisa gagal, tanpa perlu baca dokumentasi atau body implementasi. toIntOrNull() sendiri adalah contoh lain null safety dari bab 1 — mengembalikan Int?, null kalau string-nya bukan angka valid, dan ?.let { } di sini cuma dieksekusi kalau hasilnya bukan null.
Test-test berikut menunjukkan cara memakai Result:
@Test
fun `Result success membawa nilai tanpa melempar exception`() {
val result = parseQuantity("5")
assertTrue(result.isSuccess)
assertEquals(5, result.getOrNull())
}
@Test
fun `Result failure membawa exception tanpa menghentikan program`() {
val result = parseQuantity("abc")
assertTrue(result.isFailure)
assertEquals("'abc' bukan angka", result.exceptionOrNull()?.message)
}
Baik kasus sukses maupun gagal, parseQuantity("abc") tidak pernah melempar — program tetap berjalan normal, dan pemanggil memeriksa isSuccess/isFailure atau memanggil getOrNull()/exceptionOrNull() secara eksplisit sesuai kebutuhan. Lebih jauh, Result bisa dirangkai dengan fungsi seperti map dan getOrElse — pola functional yang mirip dengan operasi collection di bab 4:
@Test
fun `Result bisa dirangkai dengan map dan getOrElse tanpa try-catch`() {
val totalPrice = parseQuantity("3")
.map { quantity -> quantity * 50_000 }
.getOrElse { 0 }
assertEquals(150_000, totalPrice)
}
.map { } di sini cuma dijalankan kalau Result-nya sukses (mentransformasi nilai di dalamnya, mirip map pada List, tapi untuk satu nilai yang mungkin gagal); .getOrElse { 0 } mengambil nilai suksesnya, atau 0 sebagai default kalau gagal. Tidak ada try/catch sama sekali di titik pemakaian — alur sukses dan gagal sama-sama ditulis deklaratif lewat rangkaian fungsi.
Pendekatan 2: sealed class untuk Beberapa Jenis Kegagalan
Result<T> cocok kalau kita cuma peduli "berhasil atau gagal" secara umum. Tapi kadang ada beberapa jenis kegagalan yang berbeda, dan pemanggil perlu menangani masing-masing dengan cara berbeda — inilah saatnya sealed class, konsep yang sudah kita kenal dari bab 2:
// Pendekatan 2: sealed class - cocok saat ada beberapa jenis kegagalan yang masing-masing
// perlu ditangani berbeda oleh pemanggil.
sealed class StockCheckError {
data class ItemNotFound(val itemName: String) : StockCheckError()
data class OutOfStock(val itemName: String) : StockCheckError()
}
private fun describeStockError(itemName: String, availableStock: Map<String, Int>): StockCheckError? {
val stock = availableStock[itemName] ?: return StockCheckError.ItemNotFound(itemName)
return if (stock > 0) null else StockCheckError.OutOfStock(itemName)
}
describeStockError mengecek stok sebuah item dan mengembalikan salah satu dari tiga kemungkinan: null (tidak ada masalah, stok tersedia), ItemNotFound (item tidak dikenal sama sekali), atau OutOfStock (item dikenal tapi stoknya habis). Perhatikan bagaimana tipe kembalian StockCheckError? sekaligus menggabungkan dua konsep dari bab-bab sebelumnya: nullable untuk kasus "tidak ada error", dan sealed class untuk membedakan jenis error yang ada secara eksplisit dan exhaustive-checked oleh compiler kalau nanti kita pakai when untuk menanganinya.
@Test
fun `sealed class error membedakan jenis kegagalan secara eksplisit`() {
val stock = mapOf("Keyboard" to 12, "Mouse" to 0)
assertEquals(StockCheckError.ItemNotFound("Webcam"), describeStockError("Webcam", stock))
assertEquals(StockCheckError.OutOfStock("Mouse"), describeStockError("Mouse", stock))
assertNull(describeStockError("Keyboard", stock))
}
Bayangkan versi pendekatan exception-based untuk kasus yang sama: kita perlu dua class exception berbeda (ItemNotFoundException, OutOfStockException), lalu blok catch bertingkat di pemanggil untuk membedakan keduanya — dan tidak ada yang memaksa pemanggil menangani kedua jenis itu selain dokumentasi atau disiplin pribadi. Dengan sealed class, saat pemanggil nanti memakai when untuk mencocokkan StockCheckError, compiler akan menolak kompilasi kalau ada satu jenis error yang lupa ditangani — persis mekanisme exhaustive when yang kita bahas di bab 2 untuk PaymentResult.
Memilih Antara Keduanya
Aturan praktis yang bisa dipakai: pakai exception biasa untuk kondisi yang benar-benar tidak terduga dan tidak masuk akal ditangani di titik pemanggilan (koneksi database putus, bug internal) — biarkan itu menggelembung ke lapisan penanganan error terpusat. Pakai Result<T> untuk kegagalan biner sederhana yang memang bagian normal dari alur, di mana pemanggil cuma perlu tahu berhasil atau tidak. Pakai sealed class ketika ada beberapa jenis kegagalan berbeda yang masing-masing butuh respons berbeda dari pemanggil — misalnya ItemNotFound mungkin perlu di-response sebagai HTTP 404, sedangkan OutOfStock sebagai HTTP 409, dua status code yang berbeda untuk dua error yang berbeda pula.
Dengan ini, seluruh Fase 0 sudah selesai kita jelajahi lewat kode nyata dari order-service: null safety, OOP (data class/sealed class/object), functional programming, operasi collection, coroutines, dan error handling. Modal fundamental ini akan langsung dipakai — bukan sekadar teori — begitu Fase 1 dimulai: kita akan mulai membangun Order Service dengan Spring Boot dari nol, mulai dari setup project sampai routing dan DTO pertama.
Bagian dari Series: Kotlin Backend Microservice - Belajar Fundamental sampai Microservice Nyata
Belajar Kotlin + Spring Boot dari fundamental bahasa (null safety, OOP, coroutines) sampai membangun sistem microservice sungguhan - REST API, Postgre...
Lihat Series Lengkap