Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

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

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

Request validation and error handling in Spring Boot 3

Bean Validation on DTOs, one @RestControllerAdvice, and ProblemDetail responses that frontends can rely on.

3 min read

On this page

One place for errors

A typical Spring Boot service grows a few controllers, and each one handles bad input slightly differently. Spring Boot 3 (Spring Framework 6) includes ProblemDetail, an implementation of the application/problem+json standard (RFC 9457). Combined with Bean Validation and a single @RestControllerAdvice, every error the API returns has the same shape.

Validate at the edge with records

Request DTOs as Java records keep validation rules next to the fields:

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 on the nested record makes Spring validate payer as well. Add the starter if it is not there yet:

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

The controller only needs @Valid on the 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);
    }
}

Turn on ProblemDetail

Spring can already render its own exceptions (wrong method, unreadable JSON, and so on) as ProblemDetail. Enable it in application.yml:

spring:
  mvc:
    problemdetails:
      enabled: true

That property registers Spring Boot's own ResponseEntityExceptionHandler. It is enough for a service that does not need custom errors.

One advice for everything else

As soon as you need your own exceptions or a different validation response, write one advice that extends ResponseEntityExceptionHandler. Spring Boot then backs off and uses yours instead, and you keep the ProblemDetail rendering for all of Spring's built-in exceptions:

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;
    }
}

A failed request now returns:

{
  "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"
  }
}

The errors keys use the field path, so a frontend can attach each message to the matching input, including nested ones such as payer.email.

Business rules are not validation

Annotations are good for shape: required, length, format. Rules that need the database, such as "this policy is already lapsed", belong in the service layer. Throw a dedicated exception there and let the advice translate it:

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;
    }
}

A stable code such as POLICY_LAPSED lets clients react programmatically, while detail stays a human-readable message.

Keep 500s boring

Do not add a catch-all @ExceptionHandler(Exception.class) that returns ex.getMessage(). Internal messages can contain SQL or class names. Let unexpected errors become a generic 500, log them with a correlation id, and return that id to the client instead.

Summary

  • Validate request records with Bean Validation and @Valid.
  • Enable spring.mvc.problemdetails.enabled for Spring's own errors.
  • Extend ResponseEntityExceptionHandler once to shape validation and business errors.
  • Expose stable error codes and field paths; never expose internal messages.