StackWise
Spring Boot 4Web & REST

Bean Validation

Jakarta Validation constraints on records, nested and collection validation, groups, custom constraints and method-level validation.

beginner 3 min readLesson 8 of 23

Setup

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

This brings Hibernate Validator, the reference implementation of Jakarta Validation 3.1 (part of Jakarta EE 11 in Spring Boot 4).

Constraints on request DTOs

public record RegisterUserRequest(
        @NotBlank @Size(max = 100) String name,
        @NotBlank @Email String email,
        @NotNull @Past LocalDate dateOfBirth,
        @NotBlank @Size(min = 12, message = "must be at least {min} characters") String password,
        @Pattern(regexp = "^\\+?[0-9 ]{7,15}$") String phone,              // optional: null is valid
        @Valid @NotNull AddressRequest address,                             // cascade into nested object
        @Size(max = 5) List<@NotBlank @Size(max = 30) String> interests) {} // validate elements too

public record AddressRequest(@NotBlank String line1, @NotBlank String city,
                             @NotBlank @Size(min = 2, max = 2) String country) {}

Trigger validation with @Valid (or @Validated) on the parameter:

@PostMapping("/api/users")
ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterUserRequest request) { ... }

If validation fails, Spring MVC throws MethodArgumentNotValidException, which Boot turns into a 400 Bad Request — see Error Handling to return a clean ProblemDetail with field errors.

Common constraints

Constraint Checks Note
@NotNull not null
@NotEmpty not null and size/length > 0 strings, collections, maps, arrays
@NotBlank not null and contains a non-whitespace char strings only
@Size(min, max) length / size range
@Min, @Max, @Positive, @PositiveOrZero, @DecimalMin numeric bounds
@Email, @Pattern(regexp) format
@Past, @Future, @PastOrPresent temporal
@Digits(integer, fraction) precision of BigDecimal money

Validating path and query parameters

Constraints on simple parameters work directly in controllers (method validation is built into Spring MVC since Framework 6.1):

@GetMapping("/api/products")
List<ProductResponse> search(@RequestParam @Size(min = 2) String q,
                             @RequestParam(defaultValue = "20") @Max(100) int limit) { ... }

@GetMapping("/api/products/{sku}")
ProductResponse get(@PathVariable @Pattern(regexp = "[A-Z]{3}-\\d{4}") String sku) { ... }

Violations raise HandlerMethodValidationException (also mapped to 400).

Validation in the service layer

Annotate a bean with @Validated and Spring validates method parameters and return values through a proxy:

@Service
@Validated
class TransferService {
    public Receipt transfer(@NotBlank String from, @NotBlank String to, @Positive BigDecimal amount) { ... }
}

This throws ConstraintViolationException. Useful for messages consumed from Kafka or other non-HTTP entry points.

Validation groups

Different rules for different operations, e.g. id must be null on create but present on update:

public interface OnCreate {}
public interface OnUpdate {}

public record ProductRequest(
        @Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
        @NotBlank(groups = {OnCreate.class, OnUpdate.class}) String name) {}

@PostMapping  ProductResponse create(@Validated(OnCreate.class) @RequestBody ProductRequest r) { ... }
@PutMapping   ProductResponse update(@Validated(OnUpdate.class) @RequestBody ProductRequest r) { ... }

Often simpler: use separate request records for create and update.

Custom constraints

Single field

@Target({FIELD, PARAMETER, RECORD_COMPONENT, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = IsoCurrencyValidator.class)
public @interface IsoCurrency {
    String message() default "must be a supported ISO-4217 currency";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class IsoCurrencyValidator implements ConstraintValidator<IsoCurrency, String> {
    private static final Set<String> SUPPORTED = Set.of("GBP", "EUR", "USD");
    @Override public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value == null || SUPPORTED.contains(value);     // null handled by @NotNull
    }
}

Cross-field (class-level)

@DateRangeValid
public record BookingRequest(@NotNull LocalDate checkIn, @NotNull LocalDate checkOut) {}

public class DateRangeValidator implements ConstraintValidator<DateRangeValid, BookingRequest> {
    @Override public boolean isValid(BookingRequest b, ConstraintValidatorContext ctx) {
        if (b.checkIn() == null || b.checkOut() == null) return true;
        boolean ok = b.checkOut().isAfter(b.checkIn());
        if (!ok) {
            ctx.disableDefaultConstraintViolation();
            ctx.buildConstraintViolationWithTemplate("must be after checkIn")
               .addPropertyNode("checkOut").addConstraintViolation();
        }
        return ok;
    }
}

Validators are Spring beans, so they can inject repositories — e.g. to check that an email isn’t already registered (though uniqueness is ultimately enforced by a database constraint).

Finished this lesson?

Mark it complete to track your progress through Spring Boot 4.