Spring Boot Validation with Bean Validation API: The Architect's Guide
- Bean Validation centralizes static format checks on DTOs — no more scattered if-else blocks across controllers and services.
- @Valid triggers validation on @RequestBody; @Validated triggers validation on path variables and request parameters, and supports groups. Without either, annotations are decorative and silently ignored.
- MethodArgumentNotValidException covers @RequestBody failures; ConstraintViolationException covers path/param and service-layer failures — handle both in your @RestControllerAdvice.
- Bean Validation (JSR 380 / Jakarta Validation) enforces data constraints declaratively via annotations on DTOs — no manual if-else checks
- @Valid triggers validation on @RequestBody parameters; @Validated triggers validation on path variables and request params
- Without @Valid on the controller parameter, all annotations on the DTO are silently ignored — no error is thrown, no warning logged
- MethodArgumentNotValidException is thrown on @RequestBody validation failure; ConstraintViolationException is thrown on path variable and service-layer failures — handle both
- Validation groups let you enforce different rules for Create (ID must be null) vs Update (ID must exist) operations on the same DTO
- @NotNull on a primitive int field is meaningless — primitives cannot be null; use Integer if you need nullability
- Performance overhead is negligible — reflection is optimized in Hibernate Validator; the cost of processing bad data in your database is orders of magnitude higher
Production Incident
Production Debug GuideDiagnosing common validation failures in Spring Boot production systems
Data integrity is the backbone of any robust system. Manually writing if-else blocks to check for nulls, string lengths, and email formats is tedious, inconsistent, and pollutes your service layer with boilerplate that obscures actual business intent.
Bean Validation (JSR 380, now Jakarta Validation) solves this by letting you declare constraints directly on your DTOs using annotations like @NotNull, @Size, and @Email. Spring Boot automatically triggers these checks at the web layer via @Valid, rejecting malformed data at the earliest possible entry point — the Fail Fast principle in practice.
The trap most teams fall into is treating Bean Validation as a silver bullet for all validation concerns. It is not. It handles static format checks: email format, string length, numeric ranges, not-null. Dynamic business rules — username uniqueness, account balance sufficiency, cross-entity consistency — belong in the service layer. Bean Validation cannot query a database without creating circular dependencies and coupling your DTO to your persistence layer. Knowing where to draw that line is what separates a clean architecture from a maintenance nightmare.
What Is Spring Boot Validation with Bean Validation API and Why Does It Exist?
Bean Validation (JSR 380, now Jakarta Validation) is a specification that defines a metadata model and API for JavaBean validation. Spring Boot integrates it out of the box via spring-boot-starter-validation: you declare constraints on your DTOs using annotations, and Spring triggers validation automatically at the web layer.
The problem it solves is fragmentation. Without Bean Validation, validation logic is scattered across controllers, services, and database constraints. Each developer implements checks differently — some use if-else, some throw runtime exceptions, some silently ignore invalid data. Bean Validation centralizes the rules on the DTO itself, making them self-documenting and consistently enforced.
The trigger mechanism is straightforward. When a controller method parameter is annotated with @Valid and the request body is deserialized into a DTO, Spring invokes the Bean Validation engine (Hibernate Validator is the default and only production-ready implementation). It evaluates all constraint annotations on the DTO and its nested objects. If any constraint fails, a MethodArgumentNotValidException is thrown before the controller method body executes — the business logic never sees invalid data.
One detail that surprises engineers the first time: spring-boot-starter-web does not include the validation starter. You must explicitly add spring-boot-starter-validation to your dependencies. Without it, @Valid is present on the classpath but the Hibernate Validator engine is not registered, and validation silently does nothing.
The Fail Fast principle: Validation at the entry point means invalid data is rejected before it touches your service layer, database, or message queue. This prevents cascading failures where bad data corrupts downstream systems and makes debugging production incidents significantly harder.
package io.thecodeforge.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Data; /** * Standard DTO for user operations. * Uses Jakarta Bean Validation for declarative constraints. * * Note: age is declared as Integer (not int) intentionally. * @NotNull on a primitive int is meaningless — primitives default * to 0 and can never be null. Use the boxed type when nullability matters. */ @Data public class UserDto { @NotBlank(message = "Username is required") @Size(min = 3, max = 20, message = "Username must be between {min} and {max} characters") private String username; @Email(message = "Invalid email format") @NotBlank(message = "Email cannot be empty") private String email; @NotNull(message = "Age is required") @Min(value = 18, message = "User must be at least {value} years old") private Integer age; }
// {min}, {max}, {value} are message interpolation tokens — Hibernate Validator
// replaces them with the actual annotation attribute values at runtime.
- Annotations on DTOs define the rules — @NotNull, @Size, @Email, @Min, @Pattern
- @Valid on the controller parameter triggers the validation engine — without it, annotations are decorative and no error is thrown
- Validation runs after Jackson deserialization but before the controller method body executes
- Failed validation throws MethodArgumentNotValidException — catch it with @RestControllerAdvice for structured errors
- spring-boot-starter-validation must be in your dependencies — spring-boot-starter-web does not include it
Validation Groups — Different Rules for Create vs Update
A common production requirement is enforcing different validation rules for different operations on the same DTO. During user creation, the ID must be null — the database generates it. During user update, the ID must be present — otherwise you do not know which record to update. A single DTO with @NotNull on the ID field would reject every valid create request.
Validation groups solve this cleanly. You define marker interfaces — empty interfaces used only as type tokens — and assign constraints to specific groups. The default group (Default.class, implied when no group is specified) applies when you use @Valid or @Validated without a group argument.
In the controller, use @Validated(Group.class) instead of @Valid to specify which group to activate. Constraints assigned to other groups are ignored during that validation pass. Constraints with no group specified (belonging to Default.class) are always evaluated regardless of which group is active — this is the part most engineers miss the first time.
If your Create and Update operations have significantly different shapes, separate DTOs (CreateUserDto, UpdateUserDto) are often cleaner than a single DTO with complex group hierarchies. Groups work best when the DTOs are nearly identical and only one or two field constraints differ.
package io.thecodeforge.validation; /** * Marker interfaces for validation groups. * No methods — these are type tokens used only as group identifiers. * Constraints with no group specified belong to Default.class * and are evaluated regardless of which group is active. */ public interface ValidationGroups { interface Create {} interface Update {} } // --- package io.thecodeforge.dto; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Null; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Email; import io.thecodeforge.validation.ValidationGroups; import lombok.Data; @Data public class UserRequestDto { // Null during create (DB generates it), not-null during update. // Constraints with groups= are only evaluated when that group is active. @Null(groups = ValidationGroups.Create.class, message = "ID must be absent for creation") @NotNull(groups = ValidationGroups.Update.class, message = "ID is required for update") private Long id; // No groups= here: belongs to Default.class, always evaluated. @NotBlank(message = "Username is required") private String username; @NotBlank(message = "Email is required") @Email(message = "Invalid email format") private String email; } // --- package io.thecodeforge.controller; import io.thecodeforge.dto.UserRequestDto; import io.thecodeforge.validation.ValidationGroups; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/users") public class UserController { @PostMapping public UserRequestDto create( @Validated(ValidationGroups.Create.class) @RequestBody UserRequestDto dto) { // ID is validated as null. // username and email are validated (Default group always applies). return dto; } @PutMapping("/{id}") public UserRequestDto update( @PathVariable Long id, @Validated(ValidationGroups.Update.class) @RequestBody UserRequestDto dto) { // ID is validated as not-null. // username and email are validated (Default group always applies). return dto; } }
→ 400 Bad Request: { "field": "id", "message": "ID must be absent for creation" }
PUT /api/users/1 { "username": "alice", "email": "a@b.com" }
→ 400 Bad Request: { "field": "id", "message": "ID is required for update" }
Global Exception Handling — Returning Structured Error Responses
By default, Spring Boot returns a generic 400 response when validation fails — and depending on your Spring Boot version and configuration, it may include a stack trace. Neither is acceptable in a production API. Your API consumers need structured, parseable error messages. Your security posture requires that internal implementation details never reach the client.
The solution is @RestControllerAdvice with @ExceptionHandler methods for both MethodArgumentNotValidException (thrown by @RequestBody validation) and ConstraintViolationException (thrown by path variable, request parameter, and service-layer validation). Most teams handle the first and forget the second — then spend an afternoon debugging why path variable validation returns 500.
The structured error response format shown below is a practical standard. It includes timestamp (for log correlation), status, a human-readable message, a list of field errors (field name + constraint message), and the request path. This is everything a frontend engineer needs to display inline validation errors and everything an ops engineer needs to correlate with application logs.
package io.thecodeforge.exception; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; /** * Global exception handler for all Spring MVC controllers. * Catches both @RequestBody validation failures and path/param validation failures. * Returns structured JSON — never exposes stack traces to API consumers. */ @RestControllerAdvice public class GlobalExceptionHandler { /** * Handles @Valid / @Validated failures on @RequestBody parameters. * Thrown as MethodArgumentNotValidException by Spring MVC. */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleRequestBodyValidation( MethodArgumentNotValidException ex, WebRequest request) { List<FieldErrorDetail> fieldErrors = ex.getBindingResult() .getAllErrors() .stream() .map(error -> { String field = ((FieldError) error).getField(); String message = error.getDefaultMessage(); return new FieldErrorDetail(field, message); }) .collect(Collectors.toList()); ErrorResponse body = new ErrorResponse( LocalDateTime.now(), HttpStatus.BAD_REQUEST.value(), "Validation failed", fieldErrors, request.getDescription(false).replace("uri=", "") ); return ResponseEntity.badRequest().body(body); } /** * Handles validation failures on path variables and request parameters. * Thrown as ConstraintViolationException when @Validated is at class level. * Also handles service-layer @Validated method validation. */ @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<ErrorResponse> handleConstraintViolation( ConstraintViolationException ex, WebRequest request) { List<FieldErrorDetail> fieldErrors = ex.getConstraintViolations() .stream() .map(violation -> { // Extract the last path node as the field name String field = getFieldName(violation); String message = violation.getMessage(); return new FieldErrorDetail(field, message); }) .collect(Collectors.toList()); ErrorResponse body = new ErrorResponse( LocalDateTime.now(), HttpStatus.BAD_REQUEST.value(), "Constraint violation", fieldErrors, request.getDescription(false).replace("uri=", "") ); return ResponseEntity.badRequest().body(body); } private String getFieldName(ConstraintViolation<?> violation) { String path = violation.getPropertyPath().toString(); // Path format: methodName.paramName — return only the param name int lastDot = path.lastIndexOf('.'); return lastDot >= 0 ? path.substring(lastDot + 1) : path; } // Response types as Java records (Java 16+) public record ErrorResponse( LocalDateTime timestamp, int status, String message, List<FieldErrorDetail> errors, String path ) {} public record FieldErrorDetail(String field, String message) {} }
{
"timestamp": "2026-04-18T10:23:44.123",
"status": 400,
"message": "Validation failed",
"errors": [
{ "field": "username", "message": "Username is required" },
{ "field": "email", "message": "Invalid email format" }
],
"path": "/api/users"
}
// Path variable validation failure (@Min(1) on id):
{
"timestamp": "2026-04-18T10:23:50.456",
"status": 400,
"message": "Constraint violation",
"errors": [
{ "field": "id", "message": "must be greater than or equal to 1" }
],
"path": "/api/users/-5"
}
Cascaded Validation and Cross-Field Validation
Real DTOs are rarely flat. An OrderDto contains a CustomerDto and a List<OrderItemDto>. Bean Validation supports cascaded validation: adding @Valid on a nested field or collection field tells the engine to recurse into that object and validate its constraints as well.
Without @Valid on the nested field, the child object's constraints are never evaluated — even if the child DTO is covered in annotations. This is the number one cause of silent validation gaps in complex domain models.
Cross-field validation is a different problem. The validity of one field depends on another field's value — 'password' must match 'confirmPassword', or 'endDate' must be after 'startDate'. Bean Validation has no built-in annotation for this. The solution is a class-level custom ConstraintValidator that receives the entire DTO object and can compare any fields it needs to.
The key implementation detail most engineers miss: when a cross-field validator fails, you must use context.disableDefaultConstraintViolation() and context.buildConstraintViolationWithTemplate(...).addPropertyNode("fieldName").addConstraintViolation() to attach the error to a specific field. Without this, the error is attached at the class level and your GlobalExceptionHandler — which iterates FieldError objects — may not surface it cleanly.
package io.thecodeforge.validation; import jakarta.validation.Constraint; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import jakarta.validation.Payload; import io.thecodeforge.dto.RegistrationDto; import java.lang.annotation.*; // --- Annotation definition --- @Documented @Constraint(validatedBy = PasswordMatchValidator.class) @Target(ElementType.TYPE) // class-level — receives the entire DTO @Retention(RetentionPolicy.RUNTIME) public @interface PasswordMatch { String message() default "Passwords do not match"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } // --- Validator implementation --- public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, RegistrationDto> { @Override public boolean isValid(RegistrationDto dto, ConstraintValidatorContext context) { if (dto.getPassword() == null || dto.getConfirmPassword() == null) { // Let @NotBlank handle nulls — do not duplicate that check here return true; } boolean matches = dto.getPassword().equals(dto.getConfirmPassword()); if (!matches) { // Attach the error to confirmPassword field, not the class. // Without this, GlobalExceptionHandler may not surface the error // because it iterates FieldError objects, not class-level violations. context.disableDefaultConstraintViolation(); context .buildConstraintViolationWithTemplate("Passwords do not match") .addPropertyNode("confirmPassword") .addConstraintViolation(); } return matches; } } // --- DTO with cascaded and cross-field validation --- package io.thecodeforge.dto; import io.thecodeforge.validation.PasswordMatch; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import lombok.Data; import java.util.List; @PasswordMatch // class-level: cross-field validator @Data public class RegistrationDto { @NotBlank(message = "Password is required") @Size(min = 8, message = "Password must be at least {min} characters") private String password; private String confirmPassword; // Cascaded validation: @Valid causes Bean Validation to recurse // into AddressDto and evaluate its constraints. // Without @Valid here, AddressDto annotations are never evaluated. @Valid @NotNull(message = "Address is required") private AddressDto address; // Cascaded validation also works on collections. // @Valid validates each OrderItemDto element in the list. @Valid @NotEmpty(message = "Order must contain at least one item") private List<OrderItemDto> items; }
{ "field": "confirmPassword", "message": "Passwords do not match" }
// Cascaded failure (nested AddressDto):
{ "field": "address.street", "message": "Street is required" }
// Cascaded failure (collection element):
{ "field": "items[0].productName", "message": "Product name is required" }
Service-Layer Validation with @Validated
Bean Validation is not limited to the web layer. You can apply it to service-layer methods using @Validated at the class level and constraint annotations directly on method parameters. Spring activates AOP-based method validation via MethodValidationPostProcessor, which is automatically registered when spring-boot-starter-validation is on the classpath.
This pattern is useful when your service is called from multiple entry points — a REST controller, a scheduled job, a message listener, a CLI runner — and you want consistent validation regardless of how the service is invoked. Without service-layer validation, only the REST path gets the validation check; the message listener path does not.
The exception thrown at the service layer is ConstraintViolationException (not MethodArgumentNotValidException). Your GlobalExceptionHandler's ConstraintViolationException handler covers this automatically if you implemented both handlers as shown in the exception handling section.
package io.thecodeforge.service; import io.thecodeforge.dto.UserDto; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; /** * @Validated at the class level activates Spring AOP-based method validation. * Constraint annotations on method parameters are enforced via a proxy. * Throws ConstraintViolationException (not MethodArgumentNotValidException) * when a constraint is violated — ensure your GlobalExceptionHandler covers both. */ @Service @Validated public class UserService { /** * @Valid on the DTO parameter triggers full Bean Validation on the object. * Constraints on UserDto (username, email, age) are all evaluated. */ public UserDto createUser(@Valid UserDto dto) { // By the time execution reaches here, dto is validated. // This guarantees validation regardless of which caller invokes this method. return dto; } /** * Constraint annotations directly on primitive parameters. * @Min(1) ensures id is a positive number — no DTO wrapper needed. */ public UserDto findById(@Min(value = 1, message = "User ID must be positive") Long id) { // id is guaranteed to be >= 1 return new UserDto(); } /** * @NotBlank directly on a String parameter. */ public void deleteByUsername( @NotBlank(message = "Username must not be blank") String username) { // username is guaranteed to be non-null and non-empty } }
// ConstraintViolationException: createUser.dto.username: Username is required
// Calling userService.findById(-1L) throws:
// ConstraintViolationException: findById.id: User ID must be positive
// GlobalExceptionHandler.handleConstraintViolation() catches both
// and returns a structured 400 JSON response.
ApplicationContext.getBean()) or restructure to call across a different bean. For most architectures, keeping validation at the controller entry point and using service-layer validation only for multi-entrypoint services avoids this entirely.UserService.createUser() as the REST controller gets validation for free — no duplicated checks. The moment you add a second non-REST caller to a service, add @Validated to that service class.Common Mistakes and How to Avoid Them
Most Bean Validation mistakes come from three root causes: misunderstanding the trigger mechanism, not knowing the deserialization lifecycle, and blurring the line between static and dynamic validation.
The first trap is forgetting @Valid — the most common mistake and the most silent. Annotations on the DTO are evaluated only when @Valid or @Validated is present on the controller parameter. Without it, the annotations are decorative and no error is thrown.
The second trap is @NotNull on a primitive. If your DTO has private int age and you annotate it with @NotNull, the annotation does nothing. Java primitives cannot be null — the JVM assigns them a default value (0 for int). The constraint can never fire. Use Integer (boxed type) if nullability matters on that field.
The third trap is using Bean Validation for dynamic business rules. Checking username uniqueness requires a database query. A custom validator that injects a repository via @Autowired creates a circular dependency, couples your DTO to the persistence layer, and makes the validator a pain to unit test.
The fourth trap is confusing @Valid (Jakarta) with @Validated (Spring). For @RequestBody, either works for standard validation. For path variables and request parameters, only @Validated at the class level works. For service-layer method validation, @Validated at the class level is required.
The fifth trap — less obvious but just as dangerous — is not setting request size limits. Bean Validation runs after Jackson deserialization. A 2GB JSON payload is fully loaded into memory before any constraint is checked. Set spring.servlet.multipart.max-request-size in application.yml.
package io.thecodeforge.controller; import io.thecodeforge.dto.UserDto; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; // @Validated at class level activates path variable and request param validation. // Without this, @Min on the path variable does nothing. @Validated @RestController @RequestMapping("/api/users") public class UserController { // CORRECT: @Valid triggers @RequestBody validation. @PostMapping public UserDto createUser(@Valid @RequestBody UserDto user) { return user; } // WRONG: Missing @Valid — all UserDto annotations are silently ignored. // @PostMapping // public UserDto createUser(@RequestBody UserDto user) { ... } // CORRECT: @Validated at class level + @Min on path variable. // Throws ConstraintViolationException on invalid id. @GetMapping("/{id}") public UserDto getUser(@PathVariable @Min(value = 1, message = "ID must be positive") Long id) { return new UserDto(); } // WRONG: @Valid on path variable — does not work. // @GetMapping("/{id}") // public UserDto getUser(@PathVariable @Valid Long id) { ... } } // --- Primitive vs Boxed type trap --- // WRONG: @NotNull on int is meaningless. int can never be null. // The constraint will never fire. The DTO silently accepts any int value. public class BrokenDto { @NotNull(message = "Age is required") // dead code private int age; } // CORRECT: Use Integer (boxed) when nullability matters. public class CorrectDto { @NotNull(message = "Age is required") @Min(value = 18, message = "Must be at least 18") private Integer age; }
// With @Valid:
→ 400 Bad Request: [ { "field": "email", "message": "Invalid email format" },
{ "field": "age", "message": "User must be at least 18 years old" } ]
// Without @Valid:
→ 200 OK (invalid data silently accepted)
// GET /api/users/-5 (with @Validated at class level)
→ 400 Bad Request: [ { "field": "id", "message": "ID must be positive" } ]
| Aspect | Manual Validation (If-Else) | Bean Validation (Annotations) |
|---|---|---|
| Readability | Low — business logic buried in boilerplate checks | High — declarative, clean, self-documenting on the DTO |
| Maintenance | Difficult — validation logic spread across methods and layers | Easy — centralized on the DTO, one place to change |
| Reusability | Low — logic is copied manually between controllers and services | High — shared across all layers automatically via annotations |
| Standardization | None — every developer implements checks differently | Industry standard — JSR 380 / Jakarta Validation, consistent everywhere |
| Error Handling | Manual and inconsistent — each endpoint returns different error shapes | Centralized via @RestControllerAdvice — consistent structured JSON across all endpoints |
| Testability | Harder — validation logic embedded in controller or service methods | Easier — DTOs with constraints are tested independently with Validator.validate() |
🎯 Key Takeaways
- Bean Validation centralizes static format checks on DTOs — no more scattered if-else blocks across controllers and services.
- @Valid triggers validation on @RequestBody; @Validated triggers validation on path variables and request parameters, and supports groups. Without either, annotations are decorative and silently ignored.
- MethodArgumentNotValidException covers @RequestBody failures; ConstraintViolationException covers path/param and service-layer failures — handle both in your @RestControllerAdvice.
- Validation groups let you enforce different rules for Create vs Update on the same DTO — use marker interfaces as group identifiers and @Validated(Group.class) in the controller.
- Cascaded validation requires @Valid on nested fields and collection fields; cross-field validation requires a class-level custom ConstraintValidator with addPropertyNode() for clean field-level error attachment.
- @NotNull on a primitive type (int, long, boolean) is dead code — use boxed types (Integer, Long, Boolean) when nullability matters.
- Set spring.servlet.multipart.max-request-size in application.yml and server.error.include-stacktrace=never — size limits prevent OOM before deserialization; stack trace suppression prevents implementation leakage.
- Keep Bean Validation for static format checks. Dynamic business rules — uniqueness, balance checks, cross-entity consistency — belong in the service layer.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the technical difference between @Valid (Jakarta) and @Validated (Spring) in a Spring Boot environment?Mid-levelReveal
- QExplain how to implement a custom ConstraintValidator for a field-level validation scenario such as a specific zip code format.Mid-levelReveal
- QHow does Spring Boot handle cross-field validation where the validity of one field depends on another, such as password and confirmPassword?Mid-levelReveal
- QWhich exception is thrown when @RequestBody validation fails versus when path variable or service-layer validation fails? How do you handle both?SeniorReveal
- QHow do you validate a List of objects within a wrapper DTO?Mid-levelReveal
- QWhy is @NotNull on a primitive field meaningless and what is the correct fix?Mid-levelReveal
Frequently Asked Questions
Does Bean Validation slow down my application performance?
The overhead is negligible in 99% of applications. Hibernate Validator compiles constraint metadata once at startup and caches it — each subsequent validation call is reflection-optimized and fast. The microsecond cost of validating a DTO is irrelevant compared to the cost of processing malformed data: failed database transactions, corrupted records, debugging time, and incident response. If validation performance is ever a measurable concern, the problem is almost certainly elsewhere in the stack.
Can I use multiple annotations on a single field?
Yes. You can stack annotations freely: @NotBlank @Size(min=3, max=50) @Pattern(regexp="^[a-zA-Z0-9_]+$") on a single field. All constraints are evaluated independently and any that fail are reported together. They are evaluated in an undefined order — never write constraints that depend on each other passing first. Use @NotBlank before size or pattern checks conceptually, but the engine does not guarantee that order.
How do I validate path variables or request parameters?
Add @Validated at the class level on the controller — this activates Spring's AOP-based method parameter validation. Then annotate the method parameters directly with constraint annotations: @PathVariable @Min(1) Long id or @RequestParam @NotBlank String username. @Valid alone does not work for path variables and request parameters — it only activates validation on @RequestBody. Also ensure you have a @ExceptionHandler(ConstraintViolationException.class) in your GlobalExceptionHandler, since path variable failures throw ConstraintViolationException, not MethodArgumentNotValidException.
What is the difference between @NotNull, @NotEmpty, and @NotBlank?
@NotNull rejects null values only — an empty string ("") passes. @NotEmpty rejects null and zero-length strings or empty collections — but a string of only whitespace (" ") passes. @NotBlank rejects null, empty, and whitespace-only strings — it is the strictest option for String fields. The practical guide: use @NotBlank for any String that must contain meaningful content (username, email, description). Use @NotEmpty for collections and arrays that must have at least one element. Use @NotNull for non-String reference types and boxed numerics where any value including zero is valid but null is not.
Can I use Bean Validation outside of Spring MVC, such as in service layers?
Yes. Add @Validated at the class level on your @Service bean. Spring's MethodValidationPostProcessor (automatically registered when spring-boot-starter-validation is on the classpath) wraps the bean in an AOP proxy. Constraint annotations on method parameters are evaluated when the method is called through the proxy. Use @Valid on DTO parameters to trigger full object validation and constraint annotations directly on scalar parameters. Failures throw ConstraintViolationException. One important caveat: self-invocation within the same bean bypasses the proxy and skips validation — calls must cross a bean boundary for the proxy to intercept them.
What is message interpolation and how do I use it in constraint messages?
Hibernate Validator supports message interpolation tokens that are replaced with actual annotation attribute values at runtime. In your constraint message, wrap attribute names in curly braces: @Size(min = 3, max = 20, message = "Username must be between {min} and {max} characters"). At validation time, {min} is replaced with 3 and {max} with 20. This avoids hardcoding numbers in the message that drift out of sync when you change the annotation attributes. Standard tokens work for any annotation attribute: {value} for @Min and @Max, {min} and {max} for @Size, {regexp} for @Pattern. Custom constraint annotations support the same mechanism for any attribute you define.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.