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: trueProperti 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.enableduntuk error bawaan Spring. - Extend
ResponseEntityExceptionHandlersekali untuk membentuk error validasi dan bisnis. - Tampilkan kode error yang stabil dan path field; jangan pernah tampilkan pesan internal.