Bean Validation in Spring Boot (Jakarta Validation + Hibernate Validator, @Valid vs @Validated, groups, method validation), custom and cross-field constraints, third-party validation integration, keeping rules consistent across many forms, @RequestBody, ResponseEntity vs returning objects, HTTP methods with safety/idempotency semantics, and best practices for a scalable, maintainable inventory API.
Published September 25, 2026
At this level, validation answers should go beyond @NotNull. Cover:
REST answers should mention idempotency, status codes, versioning, pagination, and concurrency control (ETags).
Short answer: Add spring-boot-starter-validation. That brings Hibernate Validator, the reference implementation of Jakarta Bean Validation 3.x (jakarta.validation.* in Boot 3; it was javax.validation in Boot 2). Then:
@NotBlank, @Email, @Size, @Positive, @Pattern, @Future).@Valid on @RequestBody or @ModelAttribute parameters. Violations throw MethodArgumentNotValidException, which Boot turns into a 400.@Validated on a class to validate method parameters and return values (@NotNull String id, @Min(1) int page).public record CreateProductRequest(
@NotBlank @Size(max = 120) String name,
@NotNull @Positive BigDecimal price,
@PositiveOrZero int stock,
@Pattern(regexp = "[A-Z]{3}-\\d{4}") String sku) { }
@PostMapping("/products")
ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) { ... }
Common trap: since Boot 2.3, validation is not included in the web starter. Without spring-boot-starter-validation, @Valid silently does nothing.
Learn it in depth → Bean Validation with @Valid
Short answer:
@Valid @ModelAttribute("form") Form form, BindingResult result. BindingResult must come immediately after the validated parameter. If result.hasErrors(), return the form view, which renders field errors with th:errors.@RestControllerAdvice to a consistent error body (Problem Details), listing each field's error.@RestControllerAdvice
class ValidationErrors {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage())).toList());
return pd;
}
}
Learn it in depth → Global Exception Handling
Short answer: Yes, in two ways:
@Constraint(validatedBy = …), plus a ConstraintValidator<A, T>. Validators are Spring beans, so they can inject repositories or services.org.springframework.validation.Validator interface (supports plus validate(Object, Errors)), registered with @InitBinder (binder.addValidators(...)) or invoked manually. This is useful for rules that are awkward to express as annotations.@Target({FIELD, PARAMETER}) @Retention(RUNTIME)
@Constraint(validatedBy = UniqueSkuValidator.class)
public @interface UniqueSku {
String message() default "SKU already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
class UniqueSkuValidator implements ConstraintValidator<UniqueSku, String> {
private final ProductRepository repo;
UniqueSkuValidator(ProductRepository repo) { this.repo = repo; }
public boolean isValid(String sku, ConstraintValidatorContext ctx) {
return sku == null || !repo.existsBySku(sku); // leave null to @NotNull, one job per constraint
}
}
Common trap: a uniqueness check in a validator is racy. Two requests can both pass. Keep a unique database constraint as the real guarantee, and map the violation to a 409.
Short answer: Use a class-level constraint: an annotation on the DTO, and a ConstraintValidator that receives the whole object. Report the error on a specific field, so the UI can highlight it.
@DateRangeValid
public record PromotionRequest(@NotNull LocalDate startDate, @NotNull LocalDate endDate, BigDecimal discountPct, BigDecimal flatDiscount) { }
class DateRangeValidator implements ConstraintValidator<DateRangeValid, PromotionRequest> {
public boolean isValid(PromotionRequest r, ConstraintValidatorContext ctx) {
boolean ok = true;
ctx.disableDefaultConstraintViolation();
if (r.startDate() != null && r.endDate() != null && !r.endDate().isAfter(r.startDate())) {
ctx.buildConstraintViolationWithTemplate("endDate must be after startDate").addPropertyNode("endDate").addConstraintViolation();
ok = false;
}
if ((r.discountPct() == null) == (r.flatDiscount() == null)) { // exactly one of the two
ctx.buildConstraintViolationWithTemplate("specify either discountPct or flatDiscount").addPropertyNode("discountPct").addConstraintViolation();
ok = false;
}
return ok;
}
}
Key points to cover:
@Validated(OnCreate.class)) apply different rules to create and update.@GroupSequence orders the cheap checks before the expensive ones.Short answer: Yes:
@Valid/@Validated, since they plug into the same Validator. For example, Hibernate Validator's extra constraints (@URL, @CreditCardNumber, @UUID), or libraries like libphonenumber wrapped in a custom constraint.ConstraintValidator, a Spring Validator, or a filter or interceptor.Short answer:
@ValidPhone, @ValidPincode built from @Pattern plus @Size), and reuse it on every DTO.ValidationMessages.properties, which is also where i18n comes from.@Validated on services), so non-HTTP entry points (Kafka consumers, batch jobs) follow the same rules.Validator unit tests.@RequestBody do?Short answer: It binds the HTTP request body to a method parameter, through an HttpMessageConverter chosen by the Content-Type: Jackson for JSON, or JAXB/Jackson XML. It's usually combined with @Valid.
Key points to cover:
required = true by default).HttpMessageNotReadableException, which is also a 400.FAIL_ON_UNKNOWN_PROPERTIES is false by default in Boot, so misspelt fields are silently ignored.ResponseEntity and returning an object directly?Short answer:
@ResponseStatus). It's simple, and good for the standard path.ResponseEntity<T>: full control of the status, headers and body per call. For example:
201 Created with a Location header;204 No Content;304 with an ETag;@PostMapping("/products")
ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req, UriComponentsBuilder uri) {
ProductDto p = service.create(req);
return ResponseEntity.created(uri.path("/products/{id}").build(p.id())).body(p);
}
@GetMapping("/products/{id}")
ResponseEntity<ProductDto> get(@PathVariable UUID id) {
return service.find(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
Key points to cover:
@RestControllerAdvice. Use ResponseEntity where the status or headers depend on the outcome.Short answer:
The two properties that matter in design are safe (no state change) and idempotent (repeating the call has the same effect as calling once):
| Method | Safe | Idempotent |
|---|---|---|
| GET, HEAD, OPTIONS | ✅ | ✅ |
| PUT, DELETE | ❌ | ✅ |
| POST | ❌ | ❌ (make it idempotent with an Idempotency-Key header) |
| PATCH | ❌ | Not guaranteed |
Short answer:
/products?category=x&page=2: retrieval, with filters and pagination. It's cacheable, and must have no side effects./products: create, where the server assigns the ID. Returns 201 with Location. Also used for actions that don't map to CRUD (POST /orders/{id}/cancel)./products/{id}: full replacement, sending the whole representation. Idempotent, so it's safe to retry./products/{id}: partial update (JSON Merge Patch or JSON Patch). Combine it with optimistic concurrency (If-Match: <etag> returning 412 on conflict)./products/{id}: returns 204. Repeating it gives 204 or 404, and the state is the same either way.Common trap: using GET for operations that change state. Crawlers, prefetchers and retries will trigger them.
Short answer:
/warehouses/{id}/stock), with consistent naming.202 Accepted plus a status resource.ETag/Cache-Control for catalogue reads, and Redis for hot data.?fields=id,name,stock).@Version, exposed as ETags) for stock updates./v1, or media-type versioning), with additive, backward-compatible changes, and deprecation headers.Learn it in depth → API Contract Design
Q: What's the difference between @Valid and @Validated?
A: @Valid (Jakarta) triggers validation and cascades into nested objects. @Validated (Spring) supports validation groups, and on a class it enables method-level validation through a proxy. Use @Valid on request bodies and nested fields, and @Validated for groups and service-method validation.
Q: How do you validate a list in a request body?
A: Use List<@Valid ItemDto> items in the DTO (container element constraints), or @Valid @RequestBody List<ItemDto>, with @Validated on the controller in older versions. Add @Size(max = 500) to limit the batch.
Q: PUT or PATCH for updating stock quantity?
A: Neither, if it's a delta. Use an action such as POST /products/{id}/stock-adjustments {delta: -3, reason} with an idempotency key. It's auditable, and safe under concurrency. Use PUT or PATCH to set fields, with an ETag or version check.
Q: How do you return validation errors from service-layer (@Validated) validation?
A: Method validation throws ConstraintViolationException (or HandlerMethodValidationException for controller method validation in Spring 6.1+). Map both in your @RestControllerAdvice to the same Problem Details format.