Tanpa desain yang sadar, penanganan kesalahan menyebar ke seluruh kode: satu controller menangkap SQLException sendiri, controller lain mengembalikan map error bentuk lain, dan konsumen API menerima lima format kesalahan berbeda untuk lima endpoint. Spring menyediakan pemusatan penanganan lewat @RestControllerAdvice: satu kelas yang menampung seluruh kebijakan kesalahan, dari pemetaan exception ke status HTTP hingga bentuk body respons. Pada Chapter ini kita bangun advice lengkap, berkenalan dengan format ProblemDetail RFC 7807 yang menjadi standar Spring Boot 3, dan menyimak resolusi handler paling spesifik.

@RestControllerAdvice dan @ExceptionHandler #

Mekanismenya berlapis dua. Anotasi @ExceptionHandler pada method di dalam controller menangkap exception dari controller itu saja; kelas @RestControllerAdvice mengangkat method serupa agar berlaku untuk seluruh controller. Ketika exception lewat dari method controller mana pun, DispatcherServlet mencari advice yang cocok, memanggil handler-nya, dan menjadikan hasilnya respons. Berikut advice lengkap — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
@RestControllerAdvice
public class AdviceKesalahan {

    @ExceptionHandler(TidakDitemukanException.class)
    public ProblemDetail tidakDitemukan(TidakDitemukanException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, e.getMessage());
        pd.setTitle("Resource tidak ditemukan");
        return pd;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail validasi(MethodArgumentNotValidException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "Input tidak valid");
        pd.setTitle("Validasi gagal");
        pd.setProperty("field", e.getBindingResult().getFieldErrors().stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .toList());
        return pd;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail takTerduga(Exception e) {
        return ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "kesalahan tak terduga");
    }
}

Class ProblemDetail berasal dari Spring Framework 6 — implementasi bawaan atas RFC 7807, standar IETF untuk respons kesalahan HTTP dengan field type, title, status, dan detail. Body-nya diserialisasi dengan content type application/problem+json, sehingga konsumen API mendapat bentuk kesalahan yang seragam dan terdokumentasi. Properti tambahan seperti daftar field di atas dilengketkan lewat setProperty.

Pemetaan Status yang Lazim #

ExceptionStatus khasCatatan
MethodArgumentNotValidException400kegagalan @Valid pada body
HttpMessageNotReadableException400body JSON rusak/tidak terparse
TidakDitemukanException (domain)404resource tidak ada
HttpRequestMethodNotSupportedException405verb salah
HttpRequestTimeoutException (domain)409konflik konkurensi
Exception500pagar terakhir

Alternatif ringkas untuk exception domain: anotasi @ResponseStatus(HttpStatus.NOT_FOUND) langsung pada kelas exception, dan ResponseStatusException untuk kasus insidental — keduanya melengkapi, bukan menggantikan, advice.

Simulasi Resolusi Handler #

Bagian paling halus dari advice adalah resolusi: exception konkret harus jatuh ke handler paling spesifik di hierarki kelasnya. Program Java murni berikut merekonstruksi aturan itu dengan map Class -> handler dan perjalanan naik getSuperclass():

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;

public class PetaHandlerError {

    // Hierarki exception domain ala aplikasi nyata.
    static class KesalahanApi extends RuntimeException {
        KesalahanApi(String pesan) { super(pesan); }
    }

    static class TidakDitemukanException extends KesalahanApi {
        TidakDitemukanException(String pesan) { super(pesan); }
    }

    static class ValidasiGagalException extends KesalahanApi {
        ValidasiGagalException(String pesan) { super(pesan); }
    }

    // RFC 7807 ProblemDetail: format kesalahan baku Spring Boot 3.
    record ProblemDetail(int status, String title, String detail) {
        String json() {
            return "{\"type\":\"about:blank\",\"title\":\"" + title
                    + "\",\"status\":" + status
                    + ",\"detail\":\"" + detail + "\"}";
        }
    }

    public static void main(String[] args) {
        // @RestControllerAdvice mini: exception -> handler.
        Map<Class<? extends Throwable>, Function<Throwable, ProblemDetail>> advice =
                new LinkedHashMap<>();
        advice.put(TidakDitemukanException.class,
                e -> new ProblemDetail(404, "Not Found", e.getMessage()));
        advice.put(ValidasiGagalException.class,
                e -> new ProblemDetail(400, "Bad Request", e.getMessage()));
        advice.put(RuntimeException.class,
                e -> new ProblemDetail(500, "Internal Server Error", "kesalahan tak terduga"));

        layani(advice, "GET /api/produk/99", () -> {
            throw new TidakDitemukanException("Produk 99 tidak ada");
        });
        layani(advice, "POST /api/produk", () -> {
            throw new ValidasiGagalException("Kolom nama tidak boleh kosong");
        });
        layani(advice, "GET /api/laporan", () -> {
            throw new IllegalStateException("sambungan basis data terputus");
        });
    }

    static void layani(Map<Class<? extends Throwable>, Function<Throwable, ProblemDetail>> advice,
                       String permintaan, Runnable handler) {
        System.out.println("Request: " + permintaan);
        try {
            handler.run();
        } catch (Throwable e) {
            ProblemDetail pd = cariHandlerTerdekat(advice, e).apply(e);
            System.out.println("  -> " + pd.status() + " " + pd.title());
            System.out.println("  -> " + pd.json());
            System.out.println();
        }
    }

    // Pencarian handler: naik dari kelas exception konkret ke induknya,
    // persis resolusi @ExceptionHandler pada @ControllerAdvice.
    static Function<Throwable, ProblemDetail> cariHandlerTerdekat(
            Map<Class<? extends Throwable>, Function<Throwable, ProblemDetail>> advice,
            Throwable e) {
        Class<?> kelas = e.getClass();
        while (kelas != null) {
            Function<Throwable, ProblemDetail> handler = advice.get(kelas);
            if (handler != null) {
                return handler;
            }
            kelas = kelas.getSuperclass();
        }
        return x -> new ProblemDetail(500, "Internal Server Error", x.getMessage());
    }
}

Output yang Dihasilkan:

text
Request: GET /api/produk/99
  -> 404 Not Found
  -> {"type":"about:blank","title":"Not Found","status":404,"detail":"Produk 99 tidak ada"}

Request: POST /api/produk
  -> 400 Bad Request
  -> {"type":"about:blank","title":"Bad Request","status":400,"detail":"Kolom nama tidak boleh kosong"}

Request: GET /api/laporan
  -> 500 Internal Server Error
  -> {"type":"about:blank","title":"Internal Server Error","status":500,"detail":"kesalahan tak terduga"}

Mari kita bedah simulasi tersebut:

  1. Hierarki KesalahanApi: TidakDitemukanException dan ValidasiGagalException adalah turunan kelas induk yang sama — bentuk klasik exception domain di aplikasi nyata, di mana induk menjadi pagar untuk seluruh kesalahan domain.
  2. cariHandlerTerdekat(advice, e): loop dimulai dari e.getClass() — kelas paling konkret. Jika map belum memuat handler untuk kelas itu, kelas = kelas.getSuperclass() naik satu tingkat: TidakDitemukanException -> KesalahanApi -> RuntimeException. Pemberhentian pertama menang, itulah yang dimaksud handler paling spesifik.
  3. Kasus ketiga IllegalStateException: exception ini bukan turunan KesalahanApi, sehingga pencarian naik terus hingga bertemu entry RuntimeException.class — handler pagar terakhir — dan menghasilkan respons 500. Tanpa entry itu, fallback anonim di akhir method tetap menyediakan respons, menirui perilaku bawaan Spring yang tidak pernah membiarkan exception lepas tanpa respons.
  4. record ProblemDetail(...) dengan json(): method merakit string JSON RFC 7807 secara manual agar simulasi mandiri. Field-nya persis standar: type, title, status, detail.

Ketiga request di output menampilkan tiga status berbeda — 404, 400, 500 — dari satu titik kendali; itulah nilai utama advice yang terpusat.

Pola untuk Produksi #

Advice yang matang biasanya memilah tiga lapis: exception domain dengan status eksplisit, exception framework yang dipetakan ulang, dan pagar Exception yang mencatat log lengkap namun merespons hemat. Log terstruktur pada saat menangkap — mencatat correlation ID dan endpoint — menjadikan penyelidikan insiden jauh lebih cepat; kerangka log seperti ini kita bahas pada Chapter Observability Aplikasi. Sebelum melangkah ke sana, dulu kita turun satu lapisan lagi ke data: Spring Data JPA dan bagaimana exception dari database naik melalui lapisan repository.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

PetaHandlerError.java

Unduh berkas
java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;

public class PetaHandlerError {

    // Hierarki exception domain ala aplikasi nyata.
    static class KesalahanApi extends RuntimeException {
        KesalahanApi(String pesan) { super(pesan); }
    }

    static class TidakDitemukanException extends KesalahanApi {
        TidakDitemukanException(String pesan) { super(pesan); }
    }

    static class ValidasiGagalException extends KesalahanApi {
        ValidasiGagalException(String pesan) { super(pesan); }
    }

    // RFC 7807 ProblemDetail: format kesalahan baku Spring Boot 3.
    record ProblemDetail(int status, String title, String detail) {
        String json() {
            return "{\"type\":\"about:blank\",\"title\":\"" + title
                    + "\",\"status\":" + status
                    + ",\"detail\":\"" + detail + "\"}";
        }
    }

    public static void main(String[] args) {
        // @RestControllerAdvice mini: exception -> handler.
        Map<Class<? extends Throwable>, Function<Throwable, ProblemDetail>> advice =
                new LinkedHashMap<>();
        advice.put(TidakDitemukanException.class,
                e -> new ProblemDetail(404, "Not Found", e.getMessage()));
        advice.put(ValidasiGagalException.class,
                e -> new ProblemDetail(400, "Bad Request", e.getMessage()));
        advice.put(RuntimeException.class,
                e -> new ProblemDetail(500, "Internal Server Error", "kesalahan tak terduga"));

        layani(advice, "GET /api/produk/99", () -> {
            throw new TidakDitemukanException("Produk 99 tidak ada");
        });
        layani(advice, "POST /api/produk", () -> {
            throw new ValidasiGagalException("Kolom nama tidak boleh kosong");
        });
        layani(advice, "GET /api/laporan", () -> {
            throw new IllegalStateException("sambungan basis data terputus");
        });
    }

    static void layani(Map<Class<? extends Throwable>, Function<Throwable, ProblemDetail>> advice,
                       String permintaan, Runnable handler) {
        System.out.println("Request: " + permintaan);
        try {
            handler.run();
        } catch (Throwable e) {
            ProblemDetail pd = cariHandlerTerdekat(advice, e).apply(e);
            System.out.println("  -> " + pd.status() + " " + pd.title());
            System.out.println("  -> " + pd.json());
            System.out.println();
        }
    }

    // Pencarian handler: naik dari kelas exception konkret ke induknya,
    // persis resolusi @ExceptionHandler pada @ControllerAdvice.
    static Function<Throwable, ProblemDetail> cariHandlerTerdekat(
            Map<Class<? extends Throwable>, Function<Throwable, ProblemDetail>> advice,
            Throwable e) {
        Class<?> kelas = e.getClass();
        while (kelas != null) {
            Function<Throwable, ProblemDetail> handler = advice.get(kelas);
            if (handler != null) {
                return handler;
            }
            kelas = kelas.getSuperclass();
        }
        return x -> new ProblemDetail(500, "Internal Server Error", x.getMessage());
    }
}