Request Validation and Error Handling in Spring Boot

This post links to: Building a Spring Boot CRUD App With Postgres from Scratch: The Complete Guide.

This guide follows on from DTOs and Clean API Design.

We've created a REST Controller with DTOs and clean API design, congratulations! However, what happens if someone sends us a null field in a request body, or provides incorrect data (e.g. an incorrectly formatted email)? How do we nicely tell the user what's gone wrong?

You guessed it! That's where adding validation and error handling comes in. To ensure we make a robust REST API, we need to try and handle as many error eventualities as possible.

Request Validation

Why add validation? Well the main reason is to ensure that bad data doesn't enter the system. We probably don't want to allow anyone to update entities with null values!

API validation also keeps the business logic focused, separating concerns between the API requests and service logic. Adding validation can also allow us to create a consistent error response that API consumers (users, frontend developers, backend services etc.) can understand.

So if that's convinced you, let's add some validation to our application!

Firstly, I want to show you what currently happens without no validation. If we run the app and make the following request that's missing the name field:

curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email": "jon@example.com"}'

We get the following output:

{
  "id": 1,
  "name": null,
  "email": "jon@example.com"
}

Our request succeeded, but we were able to put a null value in for name. That doesn't seem like a great idea. This would potentially allow users of this app to override important values with null!

So let's fix this, first we need to add the following dependency to our build.gradle:

dependencies {
    //other deps...

    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // other deps...
}

This dependency will bring in the Jakarta validation library.

This library provides us with multiple different constraints that we can validate data against. For example:

For more currently available validation constraints, see here.

Now let's add some validation annotations to our User DTOs, we will also add an extra field called age to test the @Min & @Max annotations:

Updated User Create Request

// UserCreateRequest.java
public class UserCreateRequest {

    @NotNull(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    private String name;

    @Email(message = "Email must be valid")
    private String email;

    @Min(value = 18, message = "Age should not be less than 18")
    @Max(value = 150, message = "Age should not be greater than 150")
    private int age;

    // Constructor, Getters and setters...
}

Updated User Update Request

// UserUpdateRequest.java
public class UserUpdateRequest {

    @NotNull(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    private String name;

    @Email(message = "Email must be valid")
    private String email;

    @Min(value = 18, message = "Age should not be less than 18")
    @Max(value = 150, message = "Age should not be greater than 150")
    private int age;

    // Constructor, Getters and setters...
}

Most validation constraints require a message that can be outputted to the user.

To see the full changes to the DTOs and services, see the GitHub repo example.

Now we need to tell our Spring app to validate any DTOs provided to our application. A nice way to do this is to use the @Valid annotation.

We can add this to our UserCreateRequest & UserUpdateRequest DTOs on the controller endpoints like so:

// UserController.java
@PostMapping
public UserResponse createUser(@Valid @RequestBody final UserCreateRequest user) {

    // Controller logic...
}

@PutMapping("/{id}")
public UserResponse updateUser(@PathVariable("id") final Long id,
                               @Valid @RequestBody final UserUpdateRequest updatedUser) {

    // Controller logic...
}

This will now trigger a validation on the DTOs when API requests are made. If we run the same invalid request as before:

curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email": "jon@example.com"}'

We get a bad request response message:

{
  "timestamp": "2025-10-23T19:22:12.959+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/users"
}

And a log message in our app:

Resolved [o.s.w.b.MethodArgumentNotValidException: ...default message [Name is required]]
]

No null names allowed! The @Valid triggers the validation based on annotations in the DTO and if the DTO is invalid, Spring throws a MethodArgumentNotValidException along with a log output of the message we defined. In this case "Name is required".

Error Handling

Now we've added some validation to our API to make it more robust, we're all good right?

Well we could stop there, but this message doesn't really tell our API users much other than something they sent was wrong:

{
  "timestamp": "2025-10-23T19:22:12.959+00:00",
  "status": 400,
  "error": "Bad Request",
  "path": "/api/users"
}

But we can go one step further and tell the users exactly what the issue was by exposing that lovely message we added: "Name is required".

To do this, we can start by creating a controller advice. This controller advice can intercept any exceptions throw by our application controllers, and then we can return a more user-friendly response.

Along with the controller advice, we'll need @ExceptionHandlers. These define how we want to handle certain exceptions, like our MethodArgumentNotValidException. It's like a big try-catch block over our entire Spring Boot application.

Let's add our controller advice:

// GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(final MethodArgumentNotValidException ex) {

        final var errors = new HashMap<String, Object>();

        ex.getBindingResult()
          .getFieldErrors()
          .forEach(error -> errors.put(error.getField(), error.getDefaultMessage())
          );

        final var response = new HashMap<String, Object>();
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("errors", errors);

        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }
}

and try our invalid request again (we will add age in this request):

curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email": "jon@example.com", "age": 40}'

We now get something a bit more user-friendly:

{
  "errors": {
    "name": "Name is required"
  },
  "status": 400
}

A user can clearly see that the name field is required.

Or if we make our user Jon 1000 years old (happy 1000th bday Jon 🎉):

curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"email": "jon@example.com", "age": 1000}'

You can see the following output:

{
  "errors": {
    "name": "Name is required",
    "age": "Age should not be greater than 150"
  },
  "status": 400
}

This output now includes both validation errors for name & age.

This JSON output is great because:

Validation in the API Layer enables us to:

What's Next?

So to recap we:

GitHub Example

Up Next: Testing APIs in Spring Boot