Langsung ke konten
~/rifqi_abrory

Halaman

Beranda
Tentang
Stack
Pengalaman
Proyek
Tulisan
Kontak
Semua proyek

Proyek

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Artikel

Angular signals untuk halaman admin sehari-hari
Typed reactive forms di Angular, tanpa any
Error API yang konsisten di ASP.NET Core dengan ProblemDetails
Meneruskan CancellationToken sampai ke lapisan terbawah di ASP.NET Core
Graceful shutdown untuk service HTTP di Go
Idempotency key: membuat API pembayaran aman untuk di-retry
Form kontak yang tahan spam dengan Next.js Server Actions
Form yang ramah offline di React Native: draft dan antrean submit
Validasi request dan penanganan error di Spring Boot 3
Keyset pagination di SQL Server: ketika OFFSET mulai lambat

Aksi

Ganti ke mode gelap
Read in English
Salin alamat email
Semua artikel

Validasi request dan penanganan error di Spring Boot 3

Bean Validation di DTO, satu @RestControllerAdvice, dan respons ProblemDetail yang bisa diandalkan frontend.

3 menit baca

Di halaman ini

Satu tempat untuk semua error

Service Spring Boot biasanya bertambah beberapa controller, dan masing-masing menangani input yang salah dengan cara sedikit berbeda. Spring Boot 3 (Spring Framework 6) menyediakan ProblemDetail, implementasi standar application/problem+json (RFC 9457). Dipadukan dengan Bean Validation dan satu @RestControllerAdvice, semua error yang dikembalikan API punya bentuk yang sama.

Validasi di pintu masuk dengan record

DTO request berbentuk record Java menyimpan aturan validasi tepat di samping field-nya:

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;

public record CreatePaymentRequest(
        @NotBlank @Size(max = 20) String policyNumber,
        @NotNull @DecimalMin(value = "0.01") @Digits(integer = 13, fraction = 2) BigDecimal amount,
        @NotNull @FutureOrPresent LocalDate dueDate,
        @NotNull @Valid PayerRequest payer) {
}

public record PayerRequest(
        @NotBlank @Size(max = 100) String name,
        @NotBlank @Email String email) {
}

@Valid pada record bersarang membuat Spring ikut memvalidasi payer. Tambahkan starter-nya kalau belum ada:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Controller hanya perlu @Valid pada body:

@RestController
@RequestMapping("/api/payments")
class PaymentController {

    private final PaymentService payments;

    PaymentController(PaymentService payments) {
        this.payments = payments;
    }

    @PostMapping
    ResponseEntity<PaymentResponse> create(@Valid @RequestBody CreatePaymentRequest request) {
        var payment = payments.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(payment);
    }
}

Mengaktifkan ProblemDetail

Spring sudah bisa menampilkan exception bawaannya (method salah, JSON tidak terbaca, dan sebagainya) sebagai ProblemDetail. Aktifkan di application.yml:

spring:
  mvc:
    problemdetails:
      enabled: true

Properti itu mendaftarkan ResponseEntityExceptionHandler milik Spring Boot sendiri. Ini sudah cukup untuk service yang tidak butuh error kustom.

Satu advice untuk sisanya

Begitu Anda butuh exception sendiri atau respons validasi yang berbeda, tulis satu advice yang meng-extend ResponseEntityExceptionHandler. Spring Boot lalu mundur dan memakai milik Anda, dan tampilan ProblemDetail untuk semua exception bawaan Spring tetap berlaku:

import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {

        var problem = ProblemDetail.forStatusAndDetail(status, "One or more fields are invalid.");
        problem.setTitle("Validation failed");

        // { "amount": "must be greater than or equal to 0.01", ... }
        var errors = new java.util.LinkedHashMap<String, String>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage()));
        problem.setProperty("errors", errors);

        return ResponseEntity.status(status).body(problem);
    }

    @ExceptionHandler(NotFoundException.class)
    ProblemDetail handleNotFound(NotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(BusinessRuleException.class)
    ProblemDetail handleBusinessRule(BusinessRuleException ex) {
        var problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
        problem.setProperty("code", ex.getCode());
        return problem;
    }
}

Request yang gagal sekarang mengembalikan:

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/payments",
  "errors": {
    "amount": "must be greater than or equal to 0.01",
    "payer.email": "must be a well-formed email address"
  }
}

Key pada errors memakai path field, sehingga frontend bisa menempelkan setiap pesan ke input yang sesuai, termasuk yang bersarang seperti payer.email.

Aturan bisnis bukan validasi

Anotasi cocok untuk bentuk data: wajib diisi, panjang, format. Aturan yang butuh database, misalnya "polis ini sudah lapse", tempatnya di service layer. Lempar exception khusus di sana dan biarkan advice yang menerjemahkannya:

public class BusinessRuleException extends RuntimeException {
    private final String code;

    public BusinessRuleException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

code yang stabil seperti POLICY_LAPSED memungkinkan client bereaksi secara terprogram, sementara detail tetap berupa pesan yang mudah dibaca manusia.

Biarkan error 500 tetap membosankan

Jangan menambahkan @ExceptionHandler(Exception.class) serba-tangkap yang mengembalikan ex.getMessage(). Pesan internal bisa berisi SQL atau nama class. Biarkan error tak terduga menjadi 500 generik, catat di log dengan correlation id, lalu kembalikan id itu ke client.

Ringkasan

  • Validasi record request dengan Bean Validation dan @Valid.
  • Aktifkan spring.mvc.problemdetails.enabled untuk error bawaan Spring.
  • Extend ResponseEntityExceptionHandler sekali untuk membentuk error validasi dan bisnis.
  • Tampilkan kode error yang stabil dan path field; jangan pernah tampilkan pesan internal.