DEV Community

Vaishnavi Agrawal
Vaishnavi Agrawal

Posted on • Originally published at vaishnaviagrawal1.substack.com

Stop Bad Requests at the Door with Spring Validation

Cover image: a code window shows a POST /api/auth/register request with an empty email and a weak password, each marked with a red validation error, above an HTTP 400 Bad Request response labeled stopped by the Valid annotation at checkpoint 2.

Welcome to my newsletter. Every week I take one backend topic apart and write down what clicked, with working code from something I actually built. This week: validating REST requests.

Until I added validation, nothing in my register endpoint stopped a blank email and an empty password from sailing straight through. The first thing to complain would have been the database, five layers too late and in an error message no client could use.

For a while I handled this the obvious way: if-blocks at the top of the controller. It worked, but it looked exactly like my error handling before last week's refactor: the same checks copied into every endpoint, and controllers filling up with code that had nothing to do with their actual job.

Fixing it properly forced me to understand something more useful than any annotation: what actually happens to a request between the raw JSON arriving and my method running. Once I could see that pipeline, I knew where every kind of "bad" belongs. That is what this article is really about.

The journey of a bad request

When a POST hits my register endpoint, my controller method is not the first stop. The request has to survive three checkpoints, and each one rejects a different kind of bad:

  1. Deserialization. Jackson turns the raw JSON into my RegisterRequest object. If the JSON is malformed, or a field cannot become its Java type (text where a number belongs), the journey ends here. Validation never runs, because there is no object to validate yet.
  2. Validation. The object exists; now @Valid checks whether its contents follow my rules. Is the email shaped like an email? Is the password long enough? No database, no business context, just the data judging itself.
  3. Business rules. Only now does my method run, and the service decides things data alone cannot: does this email already exist? That check needs the database, which is why it lives in the service layer and throws my EmailAlreadyExistsException from last week, not a validation error.

The distinction that clicked for me: validation answers "is this data well-formed?", business rules answer "is this data acceptable right now?". "Email must look like an email" is checkpoint 2. "Email must not be taken" is checkpoint 3. Put a check at the wrong checkpoint and you get code that either cannot do its job or does it in the wrong place.

Each checkpoint also fails with a different exception, which matters later: checkpoint 1 throws HttpMessageNotReadableException, checkpoint 2 throws MethodArgumentNotValidException, checkpoint 3 throws whatever my service decides. Three kinds of bad, three signals.

Flow diagram of the journey of a bad request: raw JSON passes checkpoint 1 where Jackson deserializes it, checkpoint 2 where the Valid annotation checks the DTO rules, and checkpoint 3 where the service checks business rules. Each checkpoint fails with its own exception, and all three failures route into one RestControllerAdvice desk that returns a single JSON ErrorResponse.

The one dependency everyone forgets

Before any of checkpoint 2 works, the gotcha that costs people an hour: validation is not included in the web starter. Since Spring Boot 2.3, spring-boot-starter-web ships without it, so you can write validation annotations all day and @Valid will silently do nothing.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

If your validation "does not work", check this first. In Spring Boot 3 the annotations live in jakarta.validation, not the javax.validation you will still see in older tutorials.

Checkpoint 2 in practice: rules live on the DTO

Bean Validation puts the rules on the data itself. My real RegisterRequest:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class RegisterRequest {

    @NotBlank(message = "Name is required")
    @Size(max = 100, message = "Name cannot exceed 100 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Email should be valid")
    private String email;

    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(
            regexp = "^(?=.*[0-9])(?=.*[A-Z])(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).*$",
            message = "Password must contain at least one number, one uppercase letter, and one symbol"
    )
    private String password;
}
Enter fullscreen mode Exit fullscreen mode

The rules sit next to the fields they protect, the message values speak to the API caller rather than to me, and the controller knows none of this exists. The regex is three lookaheads (a digit, an uppercase letter, a symbol); @Size handles length separately, so each failure produces its own message.

One subtlety worth knowing: @NotBlank and @NotNull are not interchangeable. A password of three spaces passes @NotNull happily. @NotBlank means "not null, and not just whitespace", which is a text-only concept. That difference caused my favorite bug of this refactor, but that story comes later.

Turning the checkpoint on is one word in the controller:

@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest registerRequest) {
    AuthResponse response = authService.register(registerRequest);
    return ResponseEntity.status(201).body(response);
}
Enter fullscreen mode Exit fullscreen mode

@Valid tells Spring to run the DTO's constraints after deserialization and before my method body. If anything fails, my method never runs, and the bad data never touches the service layer.

What a failure actually contains

When checkpoint 2 fails, Spring does not hand you a string. MethodArgumentNotValidException carries a BindingResult, which holds one FieldError per broken rule: the field name, the rejected value, and the message from the annotation. And rules fail independently, even on the same field. A blank password breaks @NotBlank, @Size, and @Pattern all at once, which is three FieldErrors from a single field. Validation reports every broken rule, not just the first one it finds.

That structure is raw material. My job is to decide what shape the client sees, and I already made that decision last week: everything returns my ErrorResponse class. So the handler mines the FieldErrors and folds them into that shape:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
    String message = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.joining(", "));

    return ResponseEntity.status(400).body(
            new ErrorResponse(400, message, LocalDateTime.now())
    );
}
Enter fullscreen mode Exit fullscreen mode

One new method in the same GlobalExceptionHandler, no new error system. The client gets:

{
  "status": 400,
  "message": "email: Email is required, password: Password must be at least 8 characters",
  "timestamp": "2026-07-14T18:20:41"
}
Enter fullscreen mode Exit fullscreen mode

Same shape as a 404, same shape as a 409. A client that could parse my errors last week parses these without learning anything new.

The checkpoint I forgot to handle

Writing this article exposed a hole in my own setup. My handler covered checkpoint 2, and my custom exceptions covered checkpoint 3. But checkpoint 1 had nothing: no handler in the class mentioned HttpMessageNotReadableException, which means malformed JSON had no guaranteed answer, let alone the right one.

The right one is a 400. A 500 says "we broke"; malformed JSON means "you sent garbage". The fix is the same pattern a third time:

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleUnreadable(HttpMessageNotReadableException ex) {
    return ResponseEntity.status(400).body(
            new ErrorResponse(400, "Request body is missing or malformed", LocalDateTime.now())
    );
}
Enter fullscreen mode Exit fullscreen mode

Now all three checkpoints report through the same desk, each with an honest status code. That is the real payoff of understanding the pipeline: when a new kind of failure shows up, you know exactly which checkpoint it belongs to and where its handler goes.

Two mistakes that taught me the rules

Mistake one: @NotBlank on a number. My DecisionRequest has a BigDecimal price field. I annotated it @NotBlank like the String fields around it, sent a request, and got a 500 with UnexpectedTypeException: No validator could be found for constraint. Not a validation failure. A crash.

The reason follows from the whitespace point earlier: @NotBlank is a text-only concept, so it only works on CharSequence types. There is no such thing as a whitespace BigDecimal. For anything that is not text, the right annotation is @NotNull, and if the value itself matters there are number-specific checks like @Positive and @DecimalMin. My rule of thumb now: @NotBlank for text, @NotNull for everything else, @NotEmpty for collections that need at least one element.

Mistake two: the same rules everywhere. My login DTO originally had the full password @Pattern on it, copied straight from RegisterRequest. Looks consistent. Then I realized what it would do: if I ever tightened the complexity rule, every existing user whose password predates the rule would fail validation at login. They could not even get in to change it.

Login now only checks that email and password are present. Registration is where strength rules belong, because that is the only moment a password is being chosen. Same field, different context, different rules. Validation belongs to the use case, not to the field.

Honest notes

One simplification to own up to: in a secured app the request meets the security filter chain before any of these checkpoints. My register endpoint is open (permitAll), so the three checkpoints are the whole story for this request, but on a protected endpoint the filters get the first word. Constraints placed directly on @RequestParam or @PathVariable arguments fail with yet another exception (HandlerMethodValidationException in current Spring), so query-parameter validation needs its own handler; I have not needed it yet. Bean Validation also goes deeper than I have gone: nested objects, class-level rules that compare two fields, fully custom validators. I will write about those when my API actually uses them.

Recap

A request crosses three checkpoints: deserialization, validation, business rules. Each rejects a different kind of bad, each fails with a different exception, and all three should report through the same handler in the same error shape. Validation itself is one dependency, annotations with human-readable messages on the DTO, and @Valid on the parameter. And the two rules my mistakes taught me: match the annotation to the field's type, and match the rules to the use case.

When a request fails three validations at once, my API returns all three messages joined in one string. How do you handle it: first error only, everything in one string, or a structured per-field map? Tell me in the comments. I am genuinely curious what clients prefer.

P.S. If this was useful, subscribe on Substack. I write one piece like this every week.

Top comments (0)