Back to Spring Boot Course

Spring Boot Course

Bean Validation is the standard way to declare and enforce constraints on Java objects. It is defined by the Jakarta Bean Validation specification (formerly javax.validation — renamed as part of the Jakarta EE migration). Spring Boot integrates with the Hibernate Validator reference implementation out of the box.

Add the following dependency to enable Bean Validation in any Spring Boot application:

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

Note on package names: From Spring Boot 3.x / Jakarta EE 9+, all validation annotations moved from javax.validation.constraints to jakarta.validation.constraints. If you're on Spring Boot 2.x, use javax.validation.*; on Spring Boot 3.x, use jakarta.validation.*. The examples in this file use jakarta.validation (Spring Boot 3.x).

Bean Validation works by annotating fields, method parameters, or return values with constraint annotations. The framework evaluates them at the appropriate point (on request deserialization, method invocation, etc.).

Common Validation Annotations

  • @NotNull — the value must not be null (can be an empty String or empty Collection).
  • @NotEmpty — the value must not be null and must have at least one element/character. Applicable to String, Collection, Map, and arrays.
  • @NotBlank — applicable to Strings only; must not be null and must contain at least one non-whitespace character. Use this instead of @NotEmpty for text fields you don't want to accept as blank/whitespace-only.
  • @Size(min, max) — validates the size/length is between min and max. Applicable to String (length), Collection, Map, and arrays.
  • @Min(value) — the numeric value must be ≥ value.
  • @Max(value) — the numeric value must be ≤ value.
  • @Digits(integer, fraction) — the value must be a number with at most integer digits before the decimal point and fraction digits after.
  • @Email — validates the value is a syntactically valid email address.
  • @Pattern(regexp) — validates the String matches the given regular expression.
  • @Positive / @PositiveOrZero — the number must be strictly positive / positive or zero.
  • @Negative / @NegativeOrZero — the number must be strictly negative / negative or zero.
  • @Past / @Future — the date/time must be in the past / future relative to the current moment.
  • @PastOrPresent / @FutureOrPresent — the date/time must be past/present or future/present.

@Validated

@Validated is a Spring annotation applied at the class level (typically on controllers or service classes). When applied, it tells Spring to validate method parameters annotated with constraint annotations (@NotNull, @Size, @Valid, etc.) via Spring AOP — not just @RequestBody objects, but also @PathVariable, @RequestParam, and plain method parameters. This is what enables validation on path/query parameters in REST controllers.

@Tag(name = "Task Controller", description = "Manage tasks") @RestController @RequestMapping("/api/v1/tasks") @RequiredArgsConstructor @Validated // enables method-level parameter validation public class TaskController { private final TaskService taskService; @Operation( summary = "Get task by ID", responses = { @ApiResponse(responseCode = "200", description = "Task retrieved successfully"), @ApiResponse(responseCode = "400", description = "Invalid UUID format"), @ApiResponse(responseCode = "404", description = "Task not found"), @ApiResponse(responseCode = "500", description = "Internal server error") } ) @GetMapping("/{id}") public ResponseEntity<ApiResult<TaskResponse>> getTaskById(@PathVariable @NotNull UUID id) { TaskResponse task = taskService.getTaskById(id); return buildResponse(task, HttpStatus.OK, "Task retrieved successfully"); } private <T> ResponseEntity<ApiResult<T>> buildResponse(T data, HttpStatus status, String message) { return ResponseEntity.status(status).body(ApiResult.<T>builder() .status(status.value()) .message(message) .data(data) .timestamp(Instant.now()) .build()); } }

Calling GET /api/v1/tasks/1 (where 1 is not a valid UUID) produces a MethodArgumentTypeMismatchException. To return a clean, client-friendly error instead of a raw stack trace, handle it in your @RestControllerAdvice:

@RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity<ApiResult<String>> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) { String expectedType = ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "Unknown"; String message = String.format("Invalid value '%s' for parameter '%s'. Expected type: %s.", ex.getValue(), ex.getName(), expectedType); log.error("Type mismatch error: {}", message); return buildResponse(HttpStatus.BAD_REQUEST, "Type Mismatch Error", message); } private <T> ResponseEntity<ApiResult<T>> buildResponse(HttpStatus status, String message, T data) { return new ResponseEntity<>( ApiResult.<T>builder() .status(status.value()) .message(message) .data(data) .timestamp(Instant.now()) .build(), status ); } }

Client response:

{ "status": 400, "message": "Type Mismatch Error", "data": "Invalid value '1' for parameter 'id'. Expected type: UUID.", "timestamp": "2025-06-29T13:36:21.348303600Z" }

@Valid

@Valid is a Jakarta Bean Validation annotation applied to method parameters or fields to trigger cascaded validation of the annotated object's own constraint annotations. It's what causes @NotBlank, @Size, etc., on a request DTO's fields to be evaluated when the request comes in.

@PostMapping public ResponseEntity<ApiResult<TaskResponse>> createTask(@RequestBody @Valid TaskRequest taskRequest) { TaskResponse created = taskService.createTask(taskRequest); return buildResponse(created, HttpStatus.CREATED, "Task created successfully"); }
public record TaskRequest( UUID id, @NotBlank(message = "Title is required") @Size(max = 100, message = "Title can be up to 100 characters") String title, @Size(max = 1000, message = "Description can be up to 1000 characters") String description, @NotNull(message = "Status is required") Status status, @NotNull(message = "Priority is required") Priority priority, @Future(message = "Due date must be in the future") Instant dueDate ) {}

When @Valid validation fails, Spring throws a MethodArgumentNotValidException. Handle it in @RestControllerAdvice:

@ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ApiResult<Map<String, String>>> handleValidationErrors(MethodArgumentNotValidException ex) { Map<String, String> errors = new LinkedHashMap<>(); ex.getBindingResult().getFieldErrors().forEach(err -> errors.put(err.getField(), err.getDefaultMessage())); log.warn("Validation failed: {}", errors); return buildResponse(HttpStatus.BAD_REQUEST, "Validation failed", errors); }

Client response when dueDate and status are missing:

{ "status": 400, "message": "Validation failed", "data": { "dueDate": "Due date must be in the future", "status": "Status is required" }, "timestamp": "2025-06-29T14:04:36.258142Z" }

@Valid vs @Validated: Use @Valid on request body parameters and nested objects to trigger cascade validation of their field-level constraints. Use @Validated at the class level to enable constraint annotations directly on @PathVariable, @RequestParam, and service method parameters (which @Valid alone does not cover). They complement each other.


Custom Validators

When built-in constraints are insufficient, you can create custom constraint annotations backed by a ConstraintValidator implementation. The validator is a Spring bean and can have other beans injected into it (e.g., to load valid values from a database or configuration).

Usecase 1 — Dynamic Mandatory Field Validation via Kafka

A session service accepts arbitrary JSON maps, validates that a configurable set of mandatory fields are present, and publishes to Kafka if valid.

SessionController

@RestController @Validated @AllArgsConstructor @RequestMapping("/api/v1/session") public class SessionController { private final KafkaProducer kafkaProducer; @PostMapping("/publish-event") public ResponseEntity<?> publishEvent(@RequestBody @Valid @ValidEvent Map<String, Object> sessionMap) { kafkaProducer.publishEvent(sessionMap); return ResponseEntity.ok().build(); } }

@ValidEvent annotation:

@Constraint(validatedBy = EventValidator.class) @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface ValidEvent { String message() default "Invalid event data"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }

EventValidator (Spring-managed, can inject config):

@Component public class EventValidator implements ConstraintValidator<ValidEvent, Map<String, Object>> { @Value("${app.mandatory.fields}") private String mandatoryFieldsProperty; @Override public boolean isValid(Map<String, Object> value, ConstraintValidatorContext context) { List<String> mandatoryFields = List.of(mandatoryFieldsProperty.split(",")); List<String> missingFields = mandatoryFields.stream() .filter(field -> !value.containsKey(field) || value.get(field) == null || !StringUtils.hasText(value.get(field).toString())) .toList(); if (!missingFields.isEmpty()) { String missingFieldsMessage = missingFields.size() == 1 ? missingFields.get(0) + " field is missing" : String.join(" and ", missingFields) + " fields are missing"; context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate(missingFieldsMessage).addConstraintViolation(); return false; } return true; } }

application.yml:

app: mandatory: fields: eventId,eventName,eventTimeStamp,status

screenshot of the app

screenshot of the app


Usecase 2 — Polymorphic Event Validation with Jackson Subtypes

A business service accepts different event structures (M2M, StepUp), uses Jackson's @JsonTypeInfo/@JsonSubTypes to deserialize to the correct subtype, validates subtype-specific mandatory fields programmatically, and routes to the appropriate Kafka topic.

pom.xml (Spring Boot 3.4.2, Java 17):

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies>

BusinessEvent (abstract base with Jackson polymorphism):

@Setter @Getter @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "msgType") @JsonSubTypes({ @JsonSubTypes.Type(value = M2MEvent.class, name = "M2M"), @JsonSubTypes.Type(value = StepUpEvent.class, name = "StepUp") }) public abstract class BusinessEvent { private String msgType; }

M2MEvent:

@Getter @Setter public class M2MEvent extends BusinessEvent { private String sessionId; @NotNull private String m2mMandatoryField1; private String m2mOptionalField1; @NotNull private String m2mMandatoryField2; private String m2mOptionalField2; }

StepUpEvent:

@Getter @Setter public class StepUpEvent extends BusinessEvent { private String sessionId; @NotNull private String stepUpMandatoryField1; private String stepUpOptionalField1; @NotNull private String stepUpMandatoryField2; private String stepUpOptionalField2; }

BusinessEventController:

@RestController @RequestMapping("/api/v1/demo") @AllArgsConstructor public class BusinessEventController { private final BusinessEventService businessEventService; @PostMapping("/business-event") public ResponseEntity<?> publishBusinessEvent(@Valid @RequestBody BusinessEvent businessEvent) { businessEventService.publishBusinessEvent(businessEvent); return ResponseEntity.ok().build(); } }

BusinessEventServiceImpl — routes to correct service using Java 16+ pattern matching instanceof:

@Service @AllArgsConstructor public class BusinessEventServiceImpl implements BusinessEventService { private final ObjectMapper objectMapper; private final M2MEventService m2MEventService; private final StepUpEventService stepUpEventService; @Override public void publishBusinessEvent(BusinessEvent businessEvent) { try { System.out.println("Publishing Business Event: " + objectMapper.writeValueAsString(businessEvent)); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to serialize event", e); } if (businessEvent instanceof M2MEvent m2MEvent) { m2MEventService.publishM2MEvent(m2MEvent); } else if (businessEvent instanceof StepUpEvent stepUpEvent) { stepUpEventService.publishStepUpEvent(stepUpEvent); } else { throw new IllegalArgumentException("Unknown event type: " + businessEvent.getMsgType()); } } }

M2MEventServiceImpl — programmatic validation using jakarta.validation.Validator:

@Service @AllArgsConstructor public class M2MEventServiceImpl implements M2MEventService { private final ObjectMapper objectMapper; private final Validator validator; @Override public void publishM2MEvent(M2MEvent m2MEvent) { Set<ConstraintViolation<M2MEvent>> violations = validator.validate(m2MEvent); if (!violations.isEmpty()) { throw new ConstraintViolationException("M2MEvent validation failed", violations); } try { System.out.println("Publishing M2M Event: " + objectMapper.writeValueAsString(m2MEvent)); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to serialize M2M event", e); } } }

GlobalExceptionHandler for the business service:

@RestControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity<Map<String, String>> handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { logger.error("Invalid JSON or unknown msgType", ex); Map<String, String> error = new HashMap<>(); error.put("error", ex.getCause() instanceof InvalidTypeIdException ? "Invalid or unknown msgType value" : "Invalid JSON format or missing required fields."); return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> "Field: " + fe.getField() + " - " + fe.getDefaultMessage()) .toList(); return new ResponseEntity<>(Map.of("errors", errors), HttpStatus.BAD_REQUEST); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<Map<String, Object>> handleConstraintViolation(ConstraintViolationException ex) { return new ResponseEntity<>(Map.of("errors", List.of(ex.getMessage())), HttpStatus.BAD_REQUEST); } @ExceptionHandler(Exception.class) public ResponseEntity<Map<String, Object>> handleAll(Exception ex) { logger.error("Internal server error", ex); return new ResponseEntity<>( Map.of("status", "INTERNAL_SERVER_ERROR", "message", ex.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR ); } }