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
✦ Definition~90s read
What is Spring Boot Validation with Bean Validation API?
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.
★
Imagine you are running a high-end restaurant.
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.
Plain-English First
Imagine you are running a high-end restaurant. Instead of the chef having to stop cooking to check if the delivery driver brought fresh tomatoes or if the eggs are cracked, you have a specialized inspector at the loading dock. This inspector has a checklist: 'Tomatoes must be red,' 'Eggs must not be broken,' 'Milk must not be expired.' If the delivery does not meet these rules, it is sent back immediately — the kitchen never sees it. Spring Boot Validation is that inspector for your API, ensuring only well-formed data reaches your business logic.
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.
io/thecodeforge/dto/UserDto.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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;
/**
* StandardDTOfor user operations.
* UsesJakartaBeanValidationfor 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.
*/
@DatapublicclassUserDto {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 20, message = "Username must be between {min} and {max} characters")
privateString username;
@Email(message = "Invalid email format")
@NotBlank(message = "Email cannot be empty")
privateString email;
@NotNull(message = "Age is required")
@Min(value = 18, message = "User must be at least {value} years old")
privateInteger age;
}
Output
// DTO defined with declarative validation constraints ready for @Valid usage.
// replaces them with the actual annotation attribute values at runtime.
Bean Validation Mental Model
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
Production Insight
Without @Valid on the controller parameter, all Bean Validation annotations are silently ignored — no exception, no log entry, nothing. Invalid data passes through to the service layer as if the annotations did not exist. This is the most common and most dangerous Bean Validation mistake. Always verify validation triggers in integration tests by sending deliberately invalid data and asserting a 400 response.
Key Takeaway
Bean Validation centralizes static format checks on DTOs — no more scattered if-else blocks across controllers and services. @Valid triggers the validation engine; without it, annotations are decorative and silently ignored. The line that matters: static format checks on DTOs, dynamic business rules in the service layer. Do not cross it.
When to Use Bean Validation vs Service-Layer Validation
IfStatic format check (email format, string length, numeric range, not-null, pattern match)
→
UseUse Bean Validation annotations on the DTO — this is exactly what the specification is designed for
IfDynamic business rule (username uniqueness, account balance, inventory availability)
→
UseValidate in the service layer — Bean Validation cannot query the database without creating circular dependencies and coupling your DTO to the persistence layer
IfCross-field validation (password matches confirmPassword, end date after start date)
→
UseUse a class-level custom ConstraintValidator on the DTO — Bean Validation supports cross-field checks via @Target(ElementType.TYPE) constraints
IfDifferent rules for Create vs Update (ID null on create, ID required on update)
→
UseUse validation groups — define Create and Update marker interfaces, assign groups to constraints, use @Validated(Group.class) on the controller
IfConditional validation (if type=CREDIT then cardNumber required, else optional)
→
UseUse a class-level custom ConstraintValidator — conditional logic cannot be expressed with standard annotations alone
thecodeforge.io
Spring Boot Validation Flow with @Valid
Spring Boot Validation
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.
*/
publicinterfaceValidationGroups {
interfaceCreate {}
interfaceUpdate {}
}
// ---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;
@DatapublicclassUserRequestDto {
// 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")
privateLong id;
// No groups= here: belongs to Default.class, always evaluated.
@NotBlank(message = "Username is required")
privateString username;
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
privateString 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")
publicclassUserController {
@PostMappingpublicUserRequestDtocreate(
@Validated(ValidationGroups.Create.class) @RequestBodyUserRequestDto dto) {
// ID is validated as null.// username and email are validated (Default group always applies).return dto;
}
@PutMapping("/{id}")
publicUserRequestDtoupdate(
@PathVariableLong id,
@Validated(ValidationGroups.Update.class) @RequestBodyUserRequestDto dto) {
// ID is validated as not-null.// username and email are validated (Default group always applies).return dto;
}
}
Output
POST /api/users { "id": 5, "username": "alice", "email": "a@b.com" }
→ 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" }
Default Group Always Fires — This Surprises Engineers
When you activate a specific group with @Validated(Create.class), constraints that have no groups= attribute (belonging to Default.class) are still evaluated. Only constraints explicitly assigned to a different group are skipped. This means your @NotBlank username constraint always runs regardless of which group is active — which is almost always the correct behavior. If you need to suppress Default group constraints entirely, you must explicitly assign every constraint to a group, which quickly becomes unmanageable. Design your groups so Default applies to fields that are always required.
Production Insight
The most common groups mistake is assuming that activating Create.class skips all non-grouped constraints. It does not — Default group always fires. Test both create and update paths with deliberately invalid data in integration tests. If your CI pipeline does not send a create request with a non-null ID and assert a 400, you are relying on hope.
Key Takeaway
Validation groups let you enforce different rules for different operations on the same DTO. Constraints with no group= belong to Default.class and always fire. Constraints assigned to a specific group fire only when that group is active. Use @Validated(Group.class) in the controller — @Valid does not support groups. If Create and Update have very different shapes, separate DTOs are cleaner than complex group hierarchies.
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.
// 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"
}
Never Expose Stack Traces to Clients
Without a global exception handler, Spring Boot may expose stack traces in the error response body depending on your server.error.include-stacktrace configuration. Stack traces leak internal implementation details — class names, package structure, Spring Boot version, and dependency versions. This is a free gift to anyone probing your API for vulnerabilities. Always catch MethodArgumentNotValidException and ConstraintViolationException and return a clean, structured error response. Set server.error.include-stacktrace=never in application.yml as a defense-in-depth measure.
Production Insight
Most teams add a handler for MethodArgumentNotValidException and ship it. Three months later, someone adds path variable validation, gets a 500 instead of a 400, and spends two hours debugging it. ConstraintViolationException is a different exception — it needs its own handler. Treat them both as first-class citizens in your GlobalExceptionHandler from day one.
Key Takeaway
@RestControllerAdvice catches validation exceptions globally — every controller benefits automatically without any per-controller boilerplate. Handle both MethodArgumentNotValidException (@RequestBody failures) and ConstraintViolationException (path/param failures) — they are different exceptions. Return structured JSON with timestamp, status, field errors array, and path. Set server.error.include-stacktrace=never in application.yml.
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 @interfacePasswordMatch {
Stringmessage() default"Passwords do not match";
Class<?>[] groups() default {};
Class<? extendsPayload>[] payload() default {};
}
// --- Validator implementation ---publicclassPasswordMatchValidatorimplementsConstraintValidator<PasswordMatch, RegistrationDto> {
@OverridepublicbooleanisValid(RegistrationDto dto, ConstraintValidatorContext context) {
if (dto.getPassword() == null || dto.getConfirmPassword() == null) {
// Let @NotBlank handle nulls — do not duplicate that check herereturntrue;
}
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
@DatapublicclassRegistrationDto {
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least {min} characters")
privateString password;
privateString 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")
privateAddressDto 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")
privateList<OrderItemDto> items;
}
Output
// Cross-field failure:
{ "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" }
Cascaded vs Cross-Field — Two Different Mechanisms
Cascaded validation (@Valid on a nested field or collection) tells the engine to recurse into the child object and evaluate its own constraint annotations. The child object is validated as if it had @Valid applied directly. Cross-field validation (class-level custom ConstraintValidator) is called once per object and receives the entire DTO — you compare fields inside isValid(). These are independent mechanisms and you will often need both in the same DTO.
Production Insight
Forgetting @Valid on nested fields is the most common cause of silent validation gaps in complex DTOs. The parent DTO passes validation while a completely malformed child object flows into your service layer unchecked. Add an integration test for every nested object that sends a deliberately broken child DTO and asserts a 400 response. If that test does not exist, the validation is not verified.
Key Takeaway
Cascaded validation: add @Valid on nested fields and collection fields to trigger validation of child DTOs automatically. Without it, child annotations are never evaluated. Cross-field validation: use a class-level ConstraintValidator that receives the entire DTO. Always attach cross-field errors to a specific property node using addPropertyNode() — class-level errors are harder to surface in structured error responses.
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.
io/thecodeforge/service/UserService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 SpringAOP-based method validation.
* Constraint annotations on method parameters are enforced via a proxy.
* ThrowsConstraintViolationException (not MethodArgumentNotValidException)
* when a constraint is violated — ensure your GlobalExceptionHandler covers both.
*/
@Service
@ValidatedpublicclassUserService {
/**
* @Valid on the DTO parameter triggers full BeanValidation on the object.
* Constraints on UserDto (username, email, age) are all evaluated.
*/
publicUserDtocreateUser(@ValidUserDto 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.
*/
publicUserDtofindById(@Min(value = 1, message = "User ID must be positive") Long id) {
// id is guaranteed to be >= 1returnnewUserDto();
}
/**
* @NotBlank directly on a String parameter.
*/
publicvoiddeleteByUsername(
@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.
Self-Invocation Bypasses AOP Validation
Service-layer validation works via Spring AOP proxy. If a method within the same class calls another @Validated method directly (this.findById(id)), the call bypasses the proxy and no validation occurs. This is the standard AOP self-invocation limitation. The workaround is to inject the service into itself via @Autowired (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.
Production Insight
Service-layer validation is worth the investment when your service is invoked from more than one entry point. A Kafka consumer that calls the same 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.
Key Takeaway
@Validated at the class level on a @Service bean activates AOP-based method validation. Use @Valid on DTO parameters and constraint annotations on scalar parameters. Service-layer violations throw ConstraintViolationException — ensure your GlobalExceptionHandler handles it. Self-invocation bypasses AOP — calls must go through the proxy for validation to trigger.
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")
publicclassUserController {
// CORRECT: @Valid triggers @RequestBody validation.
@PostMappingpublicUserDtocreateUser(@Valid @RequestBodyUserDto 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}")
publicUserDtogetUser(@PathVariable @Min(value = 1, message = "ID must be positive") Long id) {
returnnewUserDto();
}
// 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.publicclassBrokenDto {
@NotNull(message = "Age is required") // dead codeprivateint age;
}
// CORRECT: Use Integer (boxed) when nullability matters.publicclassCorrectDto {
@NotNull(message = "Age is required")
@Min(value = 18, message = "Must be at least 18")
privateInteger age;
}
Output
// POST /api/users { "email": "not-an-email", "age": 16 }
{ "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" } ]
@Valid vs @Validated — Know When Each One Works
@Valid is the Jakarta standard — it triggers Bean Validation on @RequestBody and nested fields. It does not support validation groups. @Validated is Spring-specific — it supports groups and is required to activate validation on path variables, request parameters, and service-layer methods. For @RequestBody without groups, either works. For everything else, use @Validated. The practical rule: start with @Valid on @RequestBody; reach for @Validated when you need groups or are validating outside the request body.
Production Insight
The primitive vs boxed type trap silently ships broken validation to production more often than teams realize. A code reviewer sees @NotNull on an int field and assumes validation is covered. The annotation does nothing. The database then enforces a NOT NULL constraint and you get a DataIntegrityViolationException instead of a clean 400 — a much worse user experience. Make it a code review checklist item: @NotNull requires a reference type, not a primitive.
Key Takeaway
Five traps: forgetting @Valid, @NotNull on primitives, using Bean Validation for dynamic rules, confusing @Valid vs @Validated, and not setting request size limits. @Valid is Jakarta standard; @Validated is Spring-specific with group support. Always declare numeric and boolean fields as boxed types when nullability and @NotNull matter. Set spring.servlet.multipart.max-request-size — Bean Validation runs after deserialization.
Choosing @Valid vs @Validated
If@RequestBody validation without groups
→
UseUse @Valid — it is the Jakarta standard and sufficient for standard validation
If@RequestBody validation with groups (Create vs Update)
→
UseUse @Validated(Create.class) — @Valid does not support groups
IfPath variable or request parameter validation
→
UseAdd @Validated at the class level on the controller + constraint annotations on method parameters — @Valid does not work here
IfService-layer method validation
→
UseAdd @Validated at the class level on the @Service bean + constraint annotations on method parameters — activates AOP-based validation via proxy
If@NotNull on a numeric or boolean field
→
UseUse the boxed type (Integer, Boolean, Long) — @NotNull on a primitive is meaningless and silently never fires
Custom Validators — When Annotations Aren't Enough
Bean Validation has a solid library of built-in annotations, but production systems always hit the edge case that the spec authors didn't predict. Maybe you need to validate a business rule that requires a database lookup, or check a date range against today's market close. The @Pattern regex won't save you.
Spring makes this painless with the ConstraintValidator interface. Write your logic once, annotate your fields, and the framework integrates it into the same validation pipeline. No separate if-else chains scattered across controllers. No forgetting to call the validator on one code path.
The trick is keeping custom validators stateless and fast. If your validator calls an external API, you're doing it wrong. Pre-fetch data in the service layer, pass it as a field, and let the validator just check the rules. That keeps validation predictable and testable without coupling your annotation to a database session.
CustomValidatorExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// io.thecodeforge — java tutorialimport javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotDuringMarketHoursValidator.class)
public @interfaceNotDuringMarketHours {
Stringmessage() default"Cannot schedule maintenance during market hours (09:30-16:00 ET)";
Class<?>[] groups() default {};
Class<? extendsPayload>[] payload() default {};
}
classNotDuringMarketHoursValidatorimplementsConstraintValidator<NotDuringMarketHours, String> {
@OverridepublicbooleanisValid(String startTime, ConstraintValidatorContext context) {
if (startTime == null || startTime.isBlank()) {
return true; // let @NotNull handle nulls
}
// Parse "HH:mm" format; hardcoded ET zone for clarityString[] parts = startTime.split(":");
int hour = Integer.parseInt(parts[0]);
int minute = Integer.parseInt(parts[1]);
int totalMinutes = hour * 60 + minute;
int marketOpen = 9 * 60 + 30; // 09:30
int marketClose = 16 * 60; // 16:00// Block if within market hoursreturn totalMinutes < marketOpen || totalMinutes >= marketClose;
}
}
Output
When a field annotated with @NotDuringMarketHours contains '10:00', validation fails with the message: "Cannot schedule maintenance during market hours (09:30-16:00 ET)"
Production Trap: Stateful Validators
Never inject a repository or service into a ConstraintValidator. It breaks testability and couples your annotation to the Spring context. Instead, pre-load the data in your service and pass it as a field that the validator reads.
Key Takeaway
Custom validators are for business rules, not infrastructure. Keep them stateless and fast.
Validating Nested Collections — The Empty List Trap
Every team I've worked with has shipped a bug where a List<@Valid OrderItem> silently accepts an empty list or a list full of nulls. Bean Validation handles the elements inside the list, but it won't tell you if the list itself is too short or null. You get an empty invoice, and a customer escalates.
Spring Boot's validation doesn't chain @NotEmpty with @Valid automatically. You must explicitly annotate the collection with @NotEmpty (or @Size(min=1)) and@Valid. Miss either one, and your validation is a sieve.
The same rule applies to Map and Set. Map keys often need @NotBlank, and values might need @Valid. People forget the key annotation constantly. Production incident #47 in my career.
Pro tip: write a unit test that passes an empty list to your DTO's validator. If it doesn't fail, your annotation is incomplete. Catch it in CI, not at 2 AM.
NestedCollectionValidation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — java tutorialimport javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import java.util.List;
publicclassOrderRequest {
@NotBlank(message = "Customer reference is required")
privateString customerRef;
// BOTH annotations required: @NotEmpty for the list itself, @Valid for items
@NotEmpty(message = "At least one order item must be provided")
@ValidprivateList<@ValidOrderItem> items;
// Getters and setters omitted for brevity
}
classOrderItem {
@NotBlank(message = "SKU cannot be blank")
privateString sku;
privateint quantity;
}
Output
Validating an OrderRequest with an empty list:
- Violation: 'At least one order item must be provided' on field 'items'
Validating an OrderRequest with a list containing an OrderItem with sku='':
- Violation: 'SKU cannot be blank' on field 'items[0].sku'
Senior Shortcut: Test Your Annotations
Add a simple parameterized test that sends an empty collection and a collection with invalid elements. Spring Boot's MockMvc or WebTestClient makes this trivial. If you don't test validation annotations directly, you'll miss edge cases.
Key Takeaway
@NotEmpty for the container, @Valid for the contents. Always both. Never assume.
Bind Validation Errors Directly — Why @Valid in Controllers Is Your First Line of Defense
Most teams slap @Valid on a controller parameter and assume the job is done. They're half right — it triggers Bean Validation, but it doesn't tell you how to surface those errors to the client. If you don't bind the BindingResult immediately after the @Valid parameter, Spring throws a MethodArgumentNotValidException before your method body even executes. That forces you into a global handler that may not know the specific context of this endpoint.
The smarter play: always put BindingResult directly after your @Valid annotated parameter. This gives you local control over validation failures — you can log them, transform them, or even return a different HTTP status per endpoint. Production systems that need nuanced error responses (like partial success in batch endpoints) rely on this pattern. Don't let a generic handler swallow your domain-specific feedback.
["name: must not be blank", "price: must be greater than 0"]
Production Trap:
Forgetting BindingResult and relying solely on @ExceptionHandler means you lose per-endpoint error customization. Your global handler becomes a bottleneck when different APIs need different error shapes.
Key Takeaway
Always bind BindingResult to @Valid in controllers — it gives you local control over validation errors instead of forcing a one-size-fits-all global handler.
Validation on Primitive RequestParams — The Silent NPE Generator
Teams that validate JSON request bodies often forget that @RequestParam and @PathVariable endpoints need love too. Spring Boot applies validation to query parameters automatically — but only if you add @Validated at the class level. Miss that annotation, and your @Min, @NotNull, or @Pattern annotations on controller parameters are dead code. The endpoint accepts whatever garbage the client throws at it, and you get a NullPointerException when the service layer tries to use a null value.
This is especially brutal on primitive types like int or long. A missing required parameter silently defaults to 0 (for int) or 0.0 (for double), which can trigger false positives in business logic. Always annotate your controller class with @Validated, and never use primitives for optional parameters — stick to Integer, Long, or Optional types. Your service layer should never have to guess whether a 0 means 'user sent zero' or 'user sent nothing'.
Put @Validated on your controller class once, and all @RequestParam validation becomes automatic. Pair with wrapper types (Integer, Long) to differentiate 'missing' from 'zero'.
Key Takeaway
Controller-level @Validated is mandatory for @RequestParam validation — without it, your validation annotations are ignored and primitives silently default to 0.
Validation on Primitive RequestParams — The Silent NPE Generator
Spring Boot strips @RequestParam validations on primitive types like int or long without warning. When a required param is missing, Spring attempts to assign null to a primitive, throwing an IllegalArgumentException before your validator ever runs. The result: a generic 500 error instead of a meaningful 400 Bad Request. The fix is twofold. First, switch primitives to their wrapper classes (Integer, Long) so null is acceptable and triggers validation. Second, add @NotNull or @Min annotations to enforce constraints. Never rely on primitives for optional parameters. This pattern silently breaks error handling and forces clients to debug server logs. Wrapper types plus explicit validation keep your API honest and your users informed.
When calling GET /user (missing id): 500 Internal Server Error.
When calling GET /user/v2 (missing id): 400 Bad Request with "id must not be null".
Production Trap:
Primitive @RequestParams bypass global exception handlers. They throw java.lang.IllegalArgumentException before validation kicks in, producing a generic 500 that your @ControllerAdvice never catches.
Key Takeaway
Always use wrapper types (Integer, Long) for @RequestParam — primitives cause silent 500 errors on missing params.
Bind Validation Errors Directly — Why @Valid in Controllers Is Your First Line of Defense
Many developers push validation to the service layer, but this misses the point. Controller-level @Valid binds validation errors to BindingResult before any business logic runs. This stops invalid data at the API boundary, reducing boilerplate and preventing inconsistent state. When you validate only in services, you must manually check and throw exceptions for each field, duplicating logic across methods. With @Valid, Spring automatically populates BindingResult with field-specific error codes and messages. You can return these directly as 400 responses without writing custom exception handlers. The performance cost is negligible, and the security benefit is real: malformed input never reaches your database queries. Put validation upfront.
POST /users with {"email":"bad"} returns: ["email: must be a well-formed email address"]
Why This Works:
BindingResult captures field errors from @Valid before any service method executes. No try-catch, no manual validation loops — just direct, structured error responses.
Key Takeaway
Validate at the controller boundary with @Valid + BindingResult — it's simpler, faster, and more secure than service-layer validation.
Validating Nested Collections — The Empty List Trap
Spring's Bean Validation API handles nested collections inconsistently. When a list contains valid objects, @Valid on the list cascades correctly. But empty lists often bypass validation entirely, allowing null or invalid elements to slip through. The trap: a List<@Valid Address> where every element is ignored if the list itself is null or empty. Fix this by adding @NotEmpty or @Size(min=1) to the collection, plus @Valid on each element. For strict validation, use @Valid on the field and @NotNull on each collection element. Java records and DTOs with Jakarta validation annotations make this declarative. Never assume an empty list means no data — it often masks missing required sub-objects in real-world APIs.
UserDTO.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorialimport jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record UserDTO(
String name,
@NotEmpty(message = "At least one address required")
@ValidList<@jakarta.validation.constraints.NotNullAddress> addresses
) {}
record Address(
@NotEmptyString street,
@NotEmptyString city
) {}
Output
Validation fails with: "addresses: At least one address required" when list is empty.
If list has null element: "addresses[0].<list element>: must not be null".
Production Trap:
@Valid on a List does NOT enforce the list itself is non-empty. Combine @NotEmpty + @Valid to catch both empty lists and invalid elements inside.
Key Takeaway
Always pair @Valid with @NotEmpty or @Size(min=1) on collections — empty lists silently skip nested validation.
Overview
Validation is the unsung hero of robust Java applications: it catches broken data before it corrupts your domain logic, triggers silent NPEs, or compromises security. Spring Boot’s validation support, built on Bean Validation (JSR-380), shifts this burden from scattered if-else checks into declarative annotations—making your code intent-clear and testable. At its core, validation guards the boundary between untrusted input and trusted internal state. Every HTTP request, every deserialized JSON payload, every method parameter that crosses from user land into service land is a validation checkpoint. Without this layer, you trust the client to send perfect data—a dangerous gamble that leads to cryptic 500 errors, inconsistent database states, and debugging sessions that waste hours. Why does this matter? Because early failure is cheap failure. By rejecting invalid input at the controller boundary, you prevent cascading failures deeper in your stack. This section lays the foundation for every technique that follows—knowing why you validate is more important than knowing how.
ValidationOverview.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — java tutorial// 25 lines maxpublicclassValidationOverview {
publicstaticvoidmain(String[] args) {
String userInput = " "; // blank stringif (userInput == null || userInput.trim().isEmpty()) {
System.out.println("FAIL: Input must not be blank");
} else {
System.out.println("PASS: Proceed with: " + userInput);
}
// Why? Manual checks = error-prone, untestable, verbose// Spring @NotBlank does this declaratively
}
}
Output
FAIL: Input must not be blank
Production Trap:
Never validate in business logic alone. Without a controller-level guard, raw exceptions from deeper layers expose stack traces to clients — a security liability and a debugging nightmare. Always validate at the boundary first.
Key Takeaway
Validation is a defensive boundary: reject bad input early, not deep in your domain.
💬 Join the Discussion
Validation patterns are rarely one-size-fits-all — your team’s API contract, database constraints, and tolerance for error messaging will shape your approach. Did you face a scenario where a custom validator saved a production incident, or where cascading validation caught a silent null bug in a nested DTO? Share your war stories below. Real-world tips often emerge from the messiest edge cases: handling partial updates with @PatchMapping, juggling validation groups for create-vs-update flows, or integrating with frameworks like MapStruct that generate validation annotations. This is where the community sharpens practice. If you struggled with the empty list trap (you’re not alone), or if you found a clever pattern to validate cross-field dependencies without boilerplate — post it. The best insights come from developers who’ve debugged at 2 AM. Let’s build a reference that goes beyond the docs, rooted in the trenches of production Java. Your comment might save someone a sleepless night next sprint.
DiscussValidation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — java tutorial// 25 lines maxpublicclassDiscussValidation {
publicstaticvoidmain(String[] args) {
System.out.println("// Share your experience:");System.out.println("// 1. Worst validation bug you've seen?");System.out.println("// 2. Custom validator you're proud of?");System.out.println("// 3. How do you test validation edge cases?");// Open a thread below!
}
}
Output
// Share your experience:
// 1. Worst validation bug you've seen?
// 2. Custom validator you're proud of?
// 3. How do you test validation edge cases?
Production Trap:
Validation is often seen as 'boring plumbing' — but silent failures in production (e.g., a null slipping through because @Valid was missing) cost real money. Don’t let it be an afterthought.
Key Takeaway
Real-world validation wisdom is forged in comments and code reviews — share your edge case.
Conclusion
Spring Boot validation is not merely a convenience — it is a reliability contract between your application and every caller. Throughout this article, we’ve seen how a single missing @Valid annotation can turn a clean domain model into a silent NPE factory, how cascaded validation protects nested object graphs, and how custom validators give you surgical control when annotations fall short. The empty list trap and cross-field validation patterns remind us that edge cases are not rare — they are reality. By now, you should recognize validation as a first-class architectural concern, not a post-it note chore. The golden rule: validate at the boundary, fail early, fail loud. Your future self—debugging a production issue at 3 AM—will thank you for every @NotBlank, every @Valid, every custom constraint you wrote with care. Implement these patterns as standard practice, not afterthought. Your code’s resilience, your team’s velocity, and your users’ experience all depend on it. Validation isn’t glamorous, but it’s the silent guardian of production sanity.
FinalValidation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
// io.thecodeforge — java tutorial// 25 lines maxpublicclassFinalValidation {
publicstaticvoidmain(String[] args) {
System.out.println("Rule 1: Validate at controller boundaries.");
System.out.println("Rule 2: Fail early — no silent nulls.");
System.out.println("Rule 3: Customize when annotations aren't enough.");
System.out.println("Rule 4: Test validation, not assumptions.");
// Apply these and sleep better.
}
}
Output
Rule 1: Validate at controller boundaries.
Rule 2: Fail early — no silent nulls.
Rule 3: Customize when annotations aren't enough.
Rule 4: Test validation, not assumptions.
Production Trap:
Even after thorough validation, a forgotten @Valid on a nested collection can let invalid data slip through. Always double-check that cascading annotations mirror your actual object graph depth.
Key Takeaway
Validation is a code-level guardrail — implement it consistently, and your application will fail gracefully, not catastrophically.
● Production incidentPOST-MORTEMseverity: high
User Registration Accepts 2GB Payloads: Missing @Valid Crashes Production
Symptom
Production pods crash with OutOfMemoryError every few hours. Heap dumps show a single UserDto object consuming 1.8GB. Monitoring shows sporadic 503 responses during peak traffic.
Assumption
The team assumed that @Size(max=20) on the username field would limit the incoming request size. They did not realize that without @Valid on the controller parameter, that annotation is completely ignored — it exists on the class but is never evaluated.
Root cause
The @PostMapping endpoint accepted @RequestBody UserDto user without @Valid. Jackson deserialized the entire 2GB JSON payload into the UserDto object before any validation ran. The @Size and @NotBlank annotations on the DTO were decorative — Spring never evaluated them because @Valid was missing. The JVM heap was consumed by a single deserialized object, and the pod died before it could process the next request.
Fix
Added @Valid to the controller parameter: @Valid @RequestBody UserDto user. Added spring.servlet.multipart.max-request-size=1MB and spring.servlet.multipart.max-file-size=1MB to application.yml to reject oversized requests at the servlet level before deserialization begins. Added @RestControllerAdvice to catch MethodArgumentNotValidException and return structured error responses instead of 500s.
Key lesson
Without @Valid on the controller parameter, all Bean Validation annotations on the DTO are silently ignored — no exception, no log warning, nothing
Always set request size limits at the servlet level — Bean Validation runs after deserialization, which is too late for oversized payloads
Test that validation actually triggers by sending invalid data in integration tests — never assume annotations work without verifying with a failing test case
Jackson deserialization happens before validation — a malformed or oversized payload can exhaust JVM heap before any constraint is checked
Production debug guideDiagnosing common validation failures in Spring Boot production systems6 entries
Symptom · 01
Validation annotations on DTO are completely ignored — invalid data reaches the service layer
→
Fix
Check if @Valid (Jakarta) or @Validated (Spring) is present on the controller @RequestBody parameter. Without it, annotations are decorative. Also verify that spring-boot-starter-validation is declared in your pom.xml or build.gradle — omitting the dependency causes silent failure.
Symptom · 02
API returns 500 Internal Server Error instead of 400 Bad Request on validation failure
→
Fix
You are missing a @RestControllerAdvice that catches MethodArgumentNotValidException. Without it, Spring returns a generic error response or stack trace. Add a global exception handler that maps field errors to structured JSON with status 400.
Symptom · 03
Update endpoint fails because ID is null — but ID should not be required on create
→
Fix
You are using the same DTO for Create and Update without validation groups. Define a Create interface and an Update interface as group markers. Put @Null(groups = Create.class) and @NotNull(groups = Update.class) on the ID field. Use @Validated(Create.class) on the create endpoint.
Symptom · 04
Nested object validation is not triggering — child DTO fields are not validated
→
Fix
Add @Valid on the nested field in the parent DTO. Without @Valid on the field declaration, Bean Validation does not cascade into nested objects. Example: @Valid @NotNull private AddressDto address;
Symptom · 05
Custom validator is not being called — @Constraint annotation is present but isValid() never executes
→
Fix
Ensure the custom constraint annotation includes @Constraint(validatedBy = YourValidator.class) and that the validator class implements ConstraintValidator<YourAnnotation, FieldType>. If the validator uses @Autowired dependencies, it must be a Spring bean — either annotate it with @Component or register it as a bean explicitly.
Symptom · 06
Path variable validation throws 500 instead of 400 — @Min on path variable is not working
→
Fix
Path variable and request parameter validation requires @Validated at the class level on the controller. @Valid alone does not activate AOP-based method parameter validation. Also add an @ExceptionHandler(ConstraintViolationException.class) handler — path variable failures throw ConstraintViolationException, not MethodArgumentNotValidException.
Manual Validation vs Bean Validation
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
1
Bean Validation centralizes static format checks on DTOs
no more scattered if-else blocks across controllers and services.
2
@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.
3
MethodArgumentNotValidException covers @RequestBody failures; ConstraintViolationException covers path/param and service-layer failures
handle both in your @RestControllerAdvice.
4
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.
5
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.
6
@NotNull on a primitive type (int, long, boolean) is dead code
use boxed types (Integer, Long, Boolean) when nullability matters.
7
Set spring.servlet.multipart.max-request-size in application.yml and server.error.include-stacktrace=never
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
7 patterns
×
Forgetting @Valid on the controller @RequestBody parameter
Symptom
All Bean Validation annotations on the DTO are silently ignored. Invalid data — null username, malformed email, age below minimum — passes through to the service layer unchecked. No exception is thrown, no log warning is emitted.
Fix
Add @Valid before @RequestBody on every controller method that accepts a DTO requiring validation. Write an integration test that sends invalid data and asserts a 400 response — this catches missing @Valid immediately in CI.
×
@NotNull on a primitive field (int, long, boolean)
Symptom
The @NotNull constraint never fires. Primitives cannot be null — the JVM initializes them to their default values (0 for int, false for boolean). The annotation is silently dead code. The database enforces NOT NULL instead, returning a DataIntegrityViolationException (500) instead of a clean 400.
Fix
Use boxed types (Integer, Long, Boolean) when a field is optional or when nullability indicates missing input. Add @NotNull to enforce presence. Use primitives only when a default value (0, false) is semantically valid and the field will always be present.
×
Using Bean Validation for dynamic business rules such as username uniqueness
Symptom
Custom validator injects a repository via @Autowired, creating a circular dependency or coupling the DTO to the persistence layer. The validator is slow, hard to unit test, and architecturally wrong.
Fix
Keep Bean Validation for static format checks — email format, string length, numeric ranges, pattern matching. Move dynamic rules — uniqueness checks, balance sufficiency, cross-entity consistency — to the service layer where database queries are natural and testable.
×
Missing global exception handler for MethodArgumentNotValidException and ConstraintViolationException
Symptom
@RequestBody failures return 400 but with raw stack traces or Spring's default error body. Path variable failures return 500. API consumers cannot parse the error response. Internal implementation details are leaked to clients.
Fix
Add @RestControllerAdvice with handlers for both MethodArgumentNotValidException (RequestBody failures) and ConstraintViolationException (path/param and service-layer failures). Return structured JSON with timestamp, status, field errors array, and path. Set server.error.include-stacktrace=never in application.yml.
×
Using the same DTO for Create and Update without validation groups
Symptom
Create endpoint rejects valid requests because @NotNull on the ID field fires — the ID should be null for creation. Update endpoint accepts requests with null IDs, causing downstream NullPointerExceptions or incorrect database updates.
Fix
Define Create and Update marker interfaces. Put @Null(groups = Create.class) and @NotNull(groups = Update.class) on the ID field. Use @Validated(Create.class) in the create controller and @Validated(Update.class) in the update controller.
×
Not setting request size limits at the servlet level
Symptom
An oversized JSON payload is fully deserialized by Jackson into memory before Bean Validation runs. The JVM exhausts heap. Pods crash with OutOfMemoryError. One request can take down the entire pod.
Fix
Add the following to application.yml: spring.servlet.multipart.max-request-size=1MB and spring.servlet.multipart.max-file-size=1MB. This rejects oversized requests at the servlet level before Jackson begins deserialization.
×
Forgetting @Valid on nested object or collection fields in complex DTOs
Symptom
Parent DTO passes validation while a completely malformed nested child DTO flows unchecked into the service layer. A null street in AddressDto or an empty productName in OrderItemDto silently passes through.
Fix
Add @Valid on every nested object or collection field that needs validation. Example: @Valid @NotNull private AddressDto address; and @Valid @NotEmpty private List<OrderItemDto> items;
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the technical difference between @Valid (Jakarta) and @Validated...
Q02SENIOR
Explain how to implement a custom ConstraintValidator for a field-level ...
Q03SENIOR
How does Spring Boot handle cross-field validation where the validity of...
Q04SENIOR
Which exception is thrown when @RequestBody validation fails versus when...
Q05SENIOR
How do you validate a List of objects within a wrapper DTO?
Q06SENIOR
Why is @NotNull on a primitive field meaningless and what is the correct...
Q01 of 06SENIOR
What is the technical difference between @Valid (Jakarta) and @Validated (Spring) in a Spring Boot environment?
ANSWER
@Valid is the Jakarta Bean Validation standard annotation. It triggers standard Bean Validation on @RequestBody parameters and nested fields via cascading. It does not support validation groups.
@Validated is a Spring-specific annotation that extends @Valid with two additional capabilities: validation group support (you specify which group to activate, e.g., @Validated(Create.class)), and the ability to activate AOP-based method parameter validation at the class level.
For @RequestBody parameters, either @Valid or @Validated works for standard validation without groups. For path variables and request parameters, you must add @Validated at the class level on the controller — @Valid does not activate method-parameter-level validation outside of @RequestBody. For service-layer method validation, @Validated at the class level on the @Service bean is required.
Q02 of 06SENIOR
Explain how to implement a custom ConstraintValidator for a field-level validation scenario such as a specific zip code format.
ANSWER
Step 1: Define the annotation. Create an annotation class with @Constraint(validatedBy = ZipCodeValidator.class), @Target(ElementType.FIELD), and @Retention(RetentionPolicy.RUNTIME). Include the three required attributes: message(), groups(), and payload() with empty defaults.
Step 2: Implement the validator. Create a class implementing ConstraintValidator<ZipCode, String>. The initialize() method receives the annotation instance and can capture attribute values. The isValid() method contains the logic — for a zip code format, use a compiled Pattern like Pattern.compile("^\\d{5}(-\\d{4})?$") and return pattern.matcher(value).matches().
Step 3: Return true from isValid() when value is null. Let @NotNull handle null checks separately — mixing concerns in a single validator creates confusing error messages and breaks composability.
Step 4: Use @ZipCode on DTO fields. The annotation stacks with other constraints like @NotBlank.
For validators that need Spring beans (a database lookup, for example), annotate the validator with @Component. Spring automatically wires it. However, a validator that queries a database is a design smell — prefer service-layer validation for dynamic rules.
Q03 of 06SENIOR
How does Spring Boot handle cross-field validation where the validity of one field depends on another, such as password and confirmPassword?
ANSWER
Bean Validation has no built-in annotation for cross-field validation. The solution is a class-level custom ConstraintValidator.
Define an annotation @PasswordMatch with @Target(ElementType.TYPE) — placing it at the class level means the validator receives the entire DTO object. Implement PasswordMatchValidator as ConstraintValidator<PasswordMatch, RegistrationDto>. In isValid(), compare the two field values directly.
The critical implementation detail: when the validation fails, call context.disableDefaultConstraintViolation() followed by context.buildConstraintViolationWithTemplate("Passwords do not match").addPropertyNode("confirmPassword").addConstraintViolation(). This attaches the error to the confirmPassword field. Without this, the violation is attached at the class level — your GlobalExceptionHandler iterates FieldError objects and may not surface a class-level violation in the errors array.
This pattern generalizes to any cross-field dependency: date ranges, conditional required fields, matching values.
Q04 of 06SENIOR
Which exception is thrown when @RequestBody validation fails versus when path variable or service-layer validation fails? How do you handle both?
ANSWER
When @RequestBody validation fails, Spring MVC throws MethodArgumentNotValidException. It contains a BindingResult with all field errors — field name and message for each violation.
When path variable, request parameter, or service-layer method validation fails (via @Validated at the class level), the underlying Hibernate Validator throws ConstraintViolationException. This is the Jakarta Validation exception and contains a Set<ConstraintViolation<?>> with property path and message for each violation.
They are different exception types and require separate @ExceptionHandler methods in your @RestControllerAdvice.
For MethodArgumentNotValidException: iterate ex.getBindingResult().getAllErrors(), cast each to FieldError, extract getField() and getDefaultMessage().
For ConstraintViolationException: iterate ex.getConstraintViolations(), extract getPropertyPath().toString() (parse the last segment for the field name) and getMessage().
A common production mistake is handling only MethodArgumentNotValidException. Path variable validation then returns 500 instead of 400 and the team spends hours debugging what appears to be an application error.
Q05 of 06SENIOR
How do you validate a List of objects within a wrapper DTO?
ANSWER
Bean Validation does not cascade into collection elements automatically. You need two annotations on the collection field: @Valid to trigger cascaded validation of each element, and a collection-level constraint like @NotEmpty to validate the collection itself.
Example:
@Data
public class OrderDto {
@Valid
@NotEmpty(message = "Order must contain at least one item")
private List<OrderItemDto> items;
}
@Data
public class OrderItemDto {
@NotBlank(message = "Product name is required")
private String productName;
@Min(value = 1, message = "Quantity must be at least 1")
private int quantity;
}
Without @Valid on the items field, every OrderItemDto in the list is never validated regardless of how many constraint annotations it carries. @NotEmpty validates the list is non-null and non-empty. @Valid validates the contents of each element. Both are needed for complete coverage.
Q06 of 06SENIOR
Why is @NotNull on a primitive field meaningless and what is the correct fix?
ANSWER
Java primitives (int, long, double, boolean) cannot be null. The JVM initializes them to their default values — 0 for numeric types, false for boolean — before any application code runs. A @NotNull constraint checks whether the value is null. Since a primitive can never be null, the constraint can never be violated. The annotation is syntactically valid but semantically dead — it compiles, it appears on the DTO, and it does nothing.
The fix is to use the boxed (wrapper) type: Integer instead of int, Long instead of long, Boolean instead of boolean. The boxed type is a reference type and can be null. @NotNull on Integer correctly rejects a missing JSON field that deserializes to null.
The practical rule: use primitives when a default value is semantically correct and the field will always be present in the payload. Use boxed types when the field is optional or when null means 'not provided' and you want @NotNull to enforce its presence.
This trap is particularly dangerous because it passes code review — the annotation looks correct — and silently ships broken validation to production. The database NOT NULL constraint then fires instead, returning a DataIntegrityViolationException (500) rather than a clean 400 Bad Request.
01
What is the technical difference between @Valid (Jakarta) and @Validated (Spring) in a Spring Boot environment?
SENIOR
02
Explain how to implement a custom ConstraintValidator for a field-level validation scenario such as a specific zip code format.
SENIOR
03
How does Spring Boot handle cross-field validation where the validity of one field depends on another, such as password and confirmPassword?
SENIOR
04
Which exception is thrown when @RequestBody validation fails versus when path variable or service-layer validation fails? How do you handle both?
SENIOR
05
How do you validate a List of objects within a wrapper DTO?
SENIOR
06
Why is @NotNull on a primitive field meaningless and what is the correct fix?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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.
Was this helpful?
06
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.