Java

Validation and Exception Handling in Spring Boot

Executive Summary

Validation and exception handling in Java start with jakarta.validation (Bean Validation 3.0). It defines constraint annotations such as @NotNull, @NotBlank, @Size, @Email, @Min, @Max, and @Pattern. You place them on fields or record components. Then a Validator inspects an object and returns a Set of ConstraintViolation when a rule fails. This works in plain Java with zero Spring involved. Simply get a Validator from Validation.buildDefaultValidatorFactory(), call validate(object), and inspect the violations. Custom rules extend the same contract through a ConstraintValidator implementation paired with your own annotation.

In Spring Boot, exactly one integration point matters for this article. Annotate a controller parameter with @Valid, and Spring runs the same Validator automatically. On failure, it throws a MethodArgumentNotValidException. Then a @RestControllerAdvice method can translate it into a structured ProblemDetail response. However, deep coverage of Spring’s exception handling machinery belongs in the Spring Boot course on this site. That includes @ControllerAdvice, custom error models, and validation groups.

The Bean Validation Specification

jakarta.validation is a Jakarta EE specification, not a Spring library. The Bean Validation 3.0 specification defines the annotations and the Validator contract. Meanwhile, Hibernate Validator is the reference implementation. That mirrors the relationship Hibernate has to the JPA specification from the previous part of this course.

                 jakarta.validation (the SPEC)
                           |
                 Hibernate Validator (the IMPL)
                    /              \
          plain Java program      Spring Boot app
        (Validator.validate)   (@Valid auto-wires it)

  the annotations and the Validator never change between the two.
  only who calls validate() for you changes.

Without any web framework, a standalone Maven project needs just two dependencies: jakarta.validation:jakarta.validation-api and org.hibernate.validator:hibernate-validator. In short, that is the whole setup. No servlet container, no application context, nothing Spring-shaped.

The Core Annotations

Annotate a record the same way you would annotate a field in any Java class. After all, record components accept annotations directly:

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record SignupRequest(

    @NotBlank(message = "username must not be blank")
    @Size(min = 3, max = 20, message = "username must be 3 to 20 characters")
    String username,

    @NotBlank(message = "email must not be blank")
    @Email(message = "email must be a valid address")
    String email,

    @Min(value = 18, message = "age must be at least 18")
    int age
) {}
Annotation Fails when Typical field
@NotNull value is null any reference field with no sensible default
@NotBlank null, empty, or only whitespace username, name, free-text fields
@NotEmpty null or empty (whitespace allowed) a list that must have at least one element
@Size(min, max) length or collection size out of range strings, lists, maps
@Email string is not a well-formed address email
@Min / @Max numeric value out of range age, quantity, price
@Pattern(regexp) string does not match the regex postal code, SKU format
@Positive / @PositiveOrZero value is not strictly positive (or not >= 0) quantity, balance
@Past / @Future date is not before or after now birthDate, expiresAt

Each annotation accepts a message attribute. Write it the way the exceptions article insists on for exception messages. Name the field and the rule, because a caller, or a log line, sees that message first.

Running Validation and Exception Handling by Hand

Before any framework touches this, validation is three lines of plain Java. Build a factory once, reuse the Validator, call validate:

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;

public class SignupValidationDemo {
    public static void main(String[] args) {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        SignupRequest request = new SignupRequest("al", "not-an-email", 15);
        Set<ConstraintViolation<SignupRequest>> violations = validator.validate(request);

        for (ConstraintViolation<SignupRequest> v : violations) {
            System.out.println(v.getPropertyPath() + ": " + v.getMessage());
        }
        // username: username must be 3 to 20 characters
        // email: email must be a valid address
        // age: age must be at least 18
    }
}

Every violation carries a propertyPath (which field) and a message (your custom text or the annotation’s default). It also carries the invalidValue. An empty Set means the object passed every constraint. This is the entire contract that Spring, or any other caller, builds on top of. Nothing about it requires a web request.

Writing a Custom Constraint

When the built-in annotations do not express a domain rule, pair a new annotation with a ConstraintValidator. Here is a rule that rejects disposable-looking email domains, deliberately simple:

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NoDisposableDomainValidator.class)
public @interface NoDisposableDomain {
    String message() default "disposable email domains are not allowed";
    Class<?>[] groups() default {};
    Class<?>[] payload() default {};
}

public class NoDisposableDomainValidator
        implements ConstraintValidator<NoDisposableDomain, String> {

    private static final Set<String> BLOCKED = Set.of("mailinator.com", "tempmail.com");

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null) return true;              // let @NotBlank own the null case
        String domain = email.substring(email.indexOf('@') + 1).toLowerCase();
        return !BLOCKED.contains(domain);
    }
}

For example, I once wrote custom validators for a signup flow. The mistake that cost the most review time was a validator that returned false for null input. Null handling belongs to @NotNull or @NotBlank. Instead, a custom validator should treat null as valid and let the dedicated annotation report the missing value. Otherwise, every field ends up with two overlapping error messages for the same null case.

One Spring Integration Example

Everything above runs with no Spring on the classpath. In a Spring Boot controller, the one integration point is @Valid on the request body parameter. Spring fetches the same Validator from the application context and runs it before your method body executes.

@PostMapping("/signup")
public ResponseEntity<Void> signup(@Valid @RequestBody SignupRequest request) {
    // if execution reaches here, every constraint already passed
    userService.register(request);
    return ResponseEntity.ok().build();
}

A failed constraint makes Spring throw MethodArgumentNotValidException before your method runs at all. Then one handler in a @RestControllerAdvice class turns that into a structured response using ProblemDetail (RFC 9457, built into Spring since Spring Framework 6):

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    problem.setTitle("Validation failed");
    problem.setDetail(ex.getBindingResult().getFieldErrors().stream()
        .map(e -> e.getField() + ": " + e.getDefaultMessage())
        .collect(Collectors.joining("; ")));
    return problem;
}

That is the entire Spring layer for this article: one @Valid annotation, one @ExceptionHandler returning ProblemDetail. Meanwhile, the annotations, the Validator, and the ConstraintViolation messages produce the actual error content. That is the jakarta.validation code you already saw running standalone. Deep coverage of @RestControllerAdvice design, validation groups, and custom ProblemDetail extensions belongs in the Spring Boot course on this site.

How Real Systems Do This

Production services validate at the edge and trust the inside. A request arrives, and jakarta.validation constraints run once at the boundary. As a result, every layer behind that boundary receives an object that already satisfies its invariants. It is the same discipline the records article pushes toward with constructor validation. So the service layer does not re-check that an email looks like an email. Instead, it checks business rules, like whether that email already exists.

This works with Bean Validation, and not with manual if-checks, because the constraint set is declarative and inspectable. A code reviewer reads the record’s annotations and knows the full contract without reading a validate() method. For example, I once saw a team re-validate the same DTO across three layers. They used jakarta.validation annotations, then hand-written if statements in a service, and finally a database CHECK constraint. Each had slightly different rules. The three disagreed on whether an empty string counted as blank. As a result, a support ticket took two days to trace to the gap between “not null” and “not blank”.

Decision Framework

  1. Is the rule about shape (not null, length, format)? Use a built-in jakarta.validation annotation; do not hand-write the check.
  2. Is the rule domain-specific and reused across several fields or classes? Write a custom ConstraintValidator once instead of repeating logic.
  3. Does the rule depend on another field or external state (does this ID exist)? That is a business rule, not a constraint annotation. So validate it in the service layer with the exceptions article’s throw-where-it-breaks pattern.
  4. Are you inside a Spring controller? Add @Valid to the parameter and let one exception handler translate failures; do not call Validator by hand there.
  5. Are you outside Spring entirely (a batch job, a CLI tool, a library)? Call Validation.buildDefaultValidatorFactory() directly; nothing about jakarta.validation requires a framework.

When NOT to Use This

  • Do not reach for jakarta.validation to enforce rules that depend on a database lookup or another field’s live state, like uniqueness. Constraints validate the object in isolation; cross-record rules belong in the service layer.
  • Do not stack validation annotations on internal, trusted objects that never cross a system boundary. Validation earns its cost at the edge, where untrusted input arrives. Validating an already-validated internal DTO a second time just adds ceremony.
  • Do not use a custom ConstraintValidator for a one-off check used in exactly one place. A plain if statement with a clear exception, the pattern from the custom exceptions article, reads better than a four-file annotation for a single call site.

Common Mistakes

  • Treating @Valid as magic and never learning the Validator underneath. Then you cannot validate anything outside a Spring request, like a Kafka message.
  • Letting a custom validator reject null, which duplicates @NotNull or @NotBlank. It also produces two conflicting error messages for the same missing value.
  • Validating the same object three separate ways across layers (annotations, hand-written ifs, a database constraint). Each one then drifts out of sync on edge cases.
  • Forgetting that record components need the annotation on the component declaration, not on a getter. After all, records generate accessors that do not carry annotations placed elsewhere.
  • Returning raw exception stack traces instead of a structured ProblemDetail, leaking internal class names to API callers.
  • Relying only on client-side form validation and skipping server-side jakarta.validation entirely. That leaves the API wide open to any client that skips the form.

Key Takeaways

  • jakarta.validation is a Jakarta EE specification, not a Spring feature. Hibernate Validator implements it, the same spec-and-impl split as JPA and Hibernate.
  • Core annotations, @NotNull, @NotBlank, @Size, @Email, @Min, @Max, @Pattern, cover most shape-level rules declaratively.
  • A Validator built with Validation.buildDefaultValidatorFactory() runs constraints and returns ConstraintViolation objects, with zero framework dependency.
  • Custom constraints pair a new annotation with a ConstraintValidator implementation. Also, treat null as valid inside it and let @NotNull own that case.
  • Spring Boot’s entire contribution here is one @Valid parameter and one @ExceptionHandler returning ProblemDetail. Meanwhile, the validation logic itself is pure Java.
  • Validate at the system boundary, trust the object everywhere behind it, and keep cross-record business rules out of constraint annotations.
  • Deep Spring exception-handling patterns, @RestControllerAdvice design, and validation groups live in the Spring Boot course, not here.

FAQ

What is jakarta.validation used for in Java?

It is the Bean Validation specification: a set of annotations like @NotNull, @Size, and @Email you place on fields or record components, plus a Validator that checks an object against them and returns ConstraintViolation results. It works in any Java program, with or without Spring.

Do I need Spring Boot to use Bean Validation annotations?

No. Add jakarta.validation-api and Hibernate Validator to a plain Maven project, call Validation.buildDefaultValidatorFactory().getValidator(), and run validate(object) directly. Spring Boot only automates calling that same Validator for you through @Valid.

What happens when @Valid fails on a Spring controller parameter?

Spring throws MethodArgumentNotValidException before your controller method runs. A @RestControllerAdvice method with @ExceptionHandler(MethodArgumentNotValidException.class) catches it and can return a ProblemDetail response describing which fields failed and why.

How do I write a custom validation annotation in Java?

Define an annotation with @Constraint(validatedBy = YourValidator.class), then implement ConstraintValidator<YourAnnotation, FieldType> with an isValid method. Treat null as valid inside isValid and let @NotNull or @NotBlank handle the missing-value case separately.

What is the difference between @NotNull, @NotEmpty, and @NotBlank?

@NotNull only rejects null. @NotEmpty rejects null and empty (a zero-length string or collection) but allows whitespace-only strings. @NotBlank rejects null, empty, and whitespace-only strings, making it the strictest of the three for text fields.

Conclusion

Overall, validation and exception handling rest on Bean Validation, a declarative, framework-independent way to state an object’s invariants. Spring Boot’s @Valid is a thin convenience layer on top of exactly that mechanism. Learn the annotations and the Validator first, and the Spring integration stops looking like magic.

Next, the following article moves from invalid input to unauthorized access. It covers HTTP security concepts in plain Java first, then one Spring Security filter chain example.

Validate at the edge and trust the object everywhere else. That rule alone prevents most of the duplicated-validation bugs you will meet in production.

Last updated on 4 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *