DEV Community

Hai Nguyen
Hai Nguyen

Posted on

Validating REST API Requests in Spring Boot in practice

Why validation belongs at the API boundary

Imagine an API endpoint that accepts user input without any validation. One day, a client sends a request with a negative amount, an invalid email, and missing required fields. The request still reaches the service layer, corrupts business logic, and causes unexpected errors deep inside the system. The bug is hard to trace, not because the logic is complex, but because invalid data was never stopped at the boundary.

What happens when a request arrives (overview)

When a client sends an HTTP request to a Spring Boot application, a small pipeline runs before your controller code executes. Understanding that pipeline explains where validation belongs and when exceptions are thrown:

  • DispatcherServlet receives the request and resolves a controller handler.
  • Spring prepares method invocation by resolving method arguments. For @RequestBody, it uses an HttpMessageConverter to deserialize JSON into the target DTO.
  • During argument resolution, Spring integrates Jakarta Bean Validation (Hibernate Validator by default). If a controller parameter is annotated with @Valid or the controller is annotated with @Validated, the deserialized DTO is validated automatically. If any constraint violations are found, Spring raises a validation exception and does not invoke the controller method body.
  • The thrown exception is then routed through Spring's exception resolution mechanism, where application level handlers decide the HTTP response.

    Common validation exceptions you should know

  • MethodArgumentNotValidException: Raised when validating an @RequestBody fails (field-level or object-level violations). This exception carries the binding result with field errors and rejected values.

  • ConstraintViolationException: Raised when method parameter validation (@Validated on parameters, @RequestParam, @PathVariable, or programmatic validator calls) fails. The exception contains a set of ConstraintViolations describing each failure.

  • HttpMessageNotReadableException: Raised when JSON parsing fails (malformed JSON) before validation even runs.
    These exceptions are thrown before your controller logic runs. The responsibility for shaping the HTTP response falls to the configured exception handlers.

Below are the simple code snippets that illustrate how validation is declared and enforced.

// Request DTO (contract and annotations)

public class RequestPayload {
    @NotBlank
    private String name;

    @Email
    @NotBlank
    private String email;

    @NotBlank
    @DepartmentCodeValid
    private String departmentCode;
}

Enter fullscreen mode Exit fullscreen mode

Notes: the DTO declares both standard constraints (@NotBlank, @Email) and the custom domain constraint (@DepartmentCodeValid) directly on the field. This keeps the data contract and validation rules colocated and self documenting.

// Custom constraint annotation

@Documented
@Constraint(validatedBy = DepartmentCodeValidator.class)
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
public @interface DepartmentCodeValid {
    String message() default "departmentCode must be one of 50..99";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode
// Custom validator implementation
// this validator constrains that `departmentCode` must be in the 
// defined codes

public class DepartmentCodeValidator implements ConstraintValidator<DepartmentCodeValid, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return false;
        }
        try {
            int code = Integer.parseInt(value);
            for (DepartmentCode dc : DepartmentCode.values()) {
                if (dc.getCode() == code) {
                    return true;
                }
            }
            return false;
        } catch (NumberFormatException e) {
            return false;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// Domain enumeration of allowed department codes

public enum DepartmentCode {
    CODE_50(50),
    CODE_98(98),
    ...
    CODE_99(99);

    private final int code;

    DepartmentCode(int code) {
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}
Enter fullscreen mode Exit fullscreen mode
// Controller that accepts the request

@RestController
@RequestMapping("/api/requests")
public class RequestController {
    private final CacheService cacheService;

    public RequestController(CacheService cacheService) {
        this.cacheService = cacheService;
    }

    @PostMapping
    public ResponseEntity<RequestPayload> create(@Valid @RequestBody RequestPayload payload) {
        var saved = cacheService.save(payload);
        return ResponseEntity.ok(saved);
    }
}
Enter fullscreen mode Exit fullscreen mode

How validation failure is raised and routed (walkthrough)

  • When the POST handler receives a request, Spring deserializes the JSON body into RequestPayload.
  • Because the create method parameter is annotated with @Valid, the bean validation engine runs automatically.
  • The engine executes built-in constraints (@NotBlank, @Email) and the custom DepartmentCodeValidator.
  • If the custom validator rejects the departmentCode (or any other constraint fails), the engine records violations and Spring throws MethodArgumentNotValidException.
  • Spring's HandlerExceptionResolver chain (including any @RestControllerAdvice) receives that exception and decides how to convert it into an HTTP response. The controller method body is never invoked.

Centralizing validation error handling recommended

Below is an illustrative GlobalExceptionHandler you can adopt in the project. It is shown inline here to demonstrate how the binding errors and constraint violations can be transformed into a stable JSON error envelope.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleBindingErrors(MethodArgumentNotValidException ex,
                                                                       HttpServletRequest request) {
        List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage(), fe.getRejectedValue()))
            .collect(Collectors.toList());

        ValidationErrorResponse body = new ValidationErrorResponse(
            OffsetDateTime.now(), HttpStatus.BAD_REQUEST.value(), request.getRequestURI(), errors
        );
        return ResponseEntity.badRequest().body(body);
    }

    // Example: handle type mismatches or malformed values for query/path params
    @ExceptionHandler({ MethodArgumentTypeMismatchException.class })
    public ResponseEntity<ValidationErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex,
                                                                      HttpServletRequest request) {
        FieldError err = new FieldError(ex.getName(), "invalid value", ex.getValue());
        ValidationErrorResponse body = new ValidationErrorResponse(
            OffsetDateTime.now(), HttpStatus.BAD_REQUEST.value(), request.getRequestURI(), List.of(err)
        );
        return ResponseEntity.badRequest().body(body);
    }

    // Add other handlers (ConstraintViolationException, HttpMessageNotReadableException, etc.) as needed
}
Enter fullscreen mode Exit fullscreen mode
// Minimal helper DTOs used above 
record FieldError(String field, String message, Object rejectedValue){}
record ValidationErrorResponse(OffsetDateTime timestamp, int status, String path, List<FieldError> errors) {}

Enter fullscreen mode Exit fullscreen mode

Notes on the example handle

The handler reads field errors from the binding result and maps them to a compact FieldError structure. This is the most direct way to surface which property failed and why.
The same pattern can be applied to ConstraintViolationException by mapping each ConstraintViolation to a field/property path.
A consistent error envelope returned by the global handler might look like:

{
  "timestamp": "2025-12-28T12:34:56Z",
  "status": 400,
  "path": "/api/requests",
  "errors": [
    {
      "field": "departmentCode",
      "message": "departmentCode must be one of 50..99",
      "rejectedValue": "42"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

How Spring chooses which handler runs (internals, simplified)

When the validation exception is thrown, Spring consults HandlerExceptionResolvers. @RestControllerAdvice backed ExceptionHandlerExceptionResolver is consulted and, if a matching @ExceptionHandler exists, delegates to it. The handler returns a value or ResponseEntity, which Spring serializes to the HTTP response and ends processing.
If no handler is present, Spring's fallback resolvers generate a default 400 response with a framework defined body.

Conclusion

Walk through the article, we can see that validation is the first line of defense for any service. It protects business logic and persistence from malformed or malicious input, improves client experience with clear errors, and reduces debugging time for both providers and consumers. Placing validation at the boundary just after HTTP mapping but before controller logic lets you fail fast and keep deeper layers simple.

Hopefully the artical can help you to more get more understand about the validation workflow and helpful for developer career, thanks youuu... ☺️

Top comments (2)

Collapse
 
hungga1711 profile image
Hưng Trần Minh

nice

Some comments may only be visible to logged-in visitors. Sign in to view all comments.