M
Mr Sugiarto
Developer
16 Sep 2026 5 min read

Bab lalu kita berhenti di titik penting: kalau validasi gagal, Spring melempar MethodArgumentNotValidException. Tapi itu bukan satu-satunya kegagalan yang mungkin terjadi — order dengan id yang tidak ada juga harus direspons dengan jelas, bukan error 500 generik. Bab penutup Fase 1 ini membahas bagaimana Order Service menangani semua kegagalan itu secara konsisten, di satu tempat.

OrderNotFoundException — Exception Domain Sendiri

package com.indokoding.orderservice

class OrderNotFoundException(id: Long) : RuntimeException("Order dengan id $id tidak ditemukan")

Kelas ini sederhana tapi penuh makna: sebuah RuntimeException custom, khusus untuk kasus "order dengan id tertentu tidak ditemukan". Constructor-nya menerima id: Long, lalu langsung meneruskan pesan yang sudah diformat (lewat string template "Order dengan id $id tidak ditemukan") ke constructor RuntimeException di induknya. Kita sudah lihat exception ini dilempar di OrderService:

fun getOrderById(id: Long): Order =
    orderRepository.findById(id).orElseThrow { OrderNotFoundException(id) }

Kenapa bikin exception sendiri, bukan sekadar throw RuntimeException("tidak ditemukan") generik? Karena dengan tipe exception yang spesifik, kode yang menangkapnya di lapisan atas (yang akan kita lihat sebentar lagi) bisa membedakan "order tidak ditemukan" dari jenis error lain, dan memutuskan status HTTP serta respons yang tepat untuk masing-masing — bukan menebak dari isi pesan teks yang rapuh kalau berubah.

GlobalExceptionHandler — Satu Tempat untuk Semua Error

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException::class)
    fun handleNotFound(ex: OrderNotFoundException): ResponseEntity<Map<String, String>> =
        ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to ex.message.orEmpty()))

    @ExceptionHandler(IllegalArgumentException::class)
    fun handleBadRequest(ex: IllegalArgumentException): ResponseEntity<Map<String, String>> =
        ResponseEntity.badRequest().body(mapOf("error" to ex.message.orEmpty()))

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
        val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
        return ResponseEntity.badRequest().body(mapOf("error" to message))
    }
}

@RestControllerAdvice adalah gabungan @ControllerAdvice + @ResponseBody — mirip hubungan @RestController dengan @Controller yang sudah kita bahas di bab routing. Bedanya, @ControllerAdvice tidak menangani route tertentu; dia berlaku global, mengintersep exception yang dilempar dari controller mana pun di seluruh aplikasi, tidak peduli exception itu berasal dari OrderController atau controller lain yang mungkin ditambahkan nanti.

Di dalamnya, tiap method dianotasi @ExceptionHandler(NamaException::class), menetapkan: "kalau exception bertipe ini (atau turunannya) terlempar dari controller mana pun dan belum ditangani di tempat lain, jalankan method ini untuk membuat responsnya." Tiga handler di sini menangani tiga skenario:

  • OrderNotFoundException → 404 Not Found — persis exception yang kita bahas di atas, dari getOrderById. Client tahu persis: resource yang diminta memang tidak ada.
  • IllegalArgumentException → 400 Bad Request — exception generik Kotlin/Java untuk argumen yang tidak valid. Kalau kamu lihat kembali OrderService.createOrder, ada require(request.quantity >= 1) { "quantity minimal 1" } — fungsi require() di Kotlin, kalau kondisinya false, otomatis melempar IllegalArgumentException dengan pesan yang kita berikan. Handler ini menangkapnya jadi respons 400 yang rapi.
  • MethodArgumentNotValidException → 400 Bad Request dengan detail per-field — inilah exception yang kita bahas di bab validasi. Bedanya dari dua handler lain: pesannya tidak diambil langsung dari ex.message, tapi dari ex.bindingResult.fieldErrors — daftar semua field yang gagal validasi, masing-masing dengan pesan custom yang kita tulis di anotasi (@field:NotBlank(message = "...") dst). joinToString("; ") menggabungkan semuanya jadi satu string, format "field: pesan", dipisah titik koma — jadi kalau ada tiga field gagal sekaligus, client langsung tahu ketiganya dalam satu response, bukan cuma field pertama yang ketemu.

Kenapa Sentralisasi Lebih Baik dari Try/Catch Tersebar

Bayangkan tanpa GlobalExceptionHandler: setiap method di OrderController (dan controller lain yang akan ditambahkan seiring project membesar) harus membungkus logicnya sendiri-sendiri dengan try/catch, menerjemahkan setiap jenis exception ke response yang sesuai secara manual, berulang di setiap tempat. Selain menambah boilerplate, risiko yang lebih besar adalah inkonsistensi — controller A membalas error dengan bentuk {"error": "..."}, controller B (ditulis developer lain, atau ditulis lagi enam bulan kemudian oleh dirimu sendiri yang sudah lupa polanya) membalas dengan bentuk {"message": "..."} atau {"errors": [...]}. Client yang mengonsumsi API harus menghadapi beberapa bentuk error berbeda tergantung endpoint mana yang gagal — pengalaman integrasi yang buruk.

Dengan satu @RestControllerAdvice, aturan mainnya jadi jelas dan satu tempat: semua error di seluruh Order Service, dari controller mana pun, exception apa pun yang kita daftarkan, selalu keluar dalam bentuk {"error": "pesan"} dengan status HTTP yang tepat. Controller sendiri jadi lebih bersih — cukup fokus pada alur "bahagia" (happy path), tanpa perlu memikirkan bagaimana membungkus tiap kegagalan.

Fase 1 Selesai

Enam bab ini menutup Fase 1 — kita sudah membangun Order Service dari nol: setup project dan struktur Gradle, memahami dependency injection dan configuration properties, merancang routing dan controller, memisahkan DTO dari domain model, menegakkan validasi request, sampai menyeragamkan response error lewat satu exception handler terpusat. Order Service sekarang bisa menerima order, mencarinya, dan menolak input yang tidak valid dengan respons yang jelas. Yang belum kita bahas sama sekali: bagaimana orderRepository di balik OrderService sebenarnya bekerja — bagaimana data order itu betul-betul tersimpan dan bisa dicari kembali. Fase 2 berikutnya: menyambungkan Order Service ke database PostgreSQL sungguhan, lewat Spring Data JPA dan migration terkelola dengan Flyway.

M
Mr Sugiarto

Developer

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
Artikel Terkait
Validasi Request
Validasi Request
1 hari yang lalu
DTO dengan Data Class
DTO dengan Data Class
2 hari yang lalu
Routing & Controller
Routing & Controller
3 hari yang lalu
Artikel Sebelumnya
Validasi Request