Mid-level 13 min · March 09, 2026

Spring Boot Validation — 2GB Payload Crashes Without @Valid

Missing @Valid caused OOM: 2GB JSON payload crashed production pods.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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;

/**
 * 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;
}
Output
// DTO defined with declarative validation constraints ready for @Valid usage.
// {min}, {max}, {value} are message interpolation tokens — Hibernate Validator
// 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
Spring Boot Validation Flow with @Valid THECODEFORGE.IO Spring Boot Validation Flow with @Valid From request to validated object, handling groups and errors Request with @Valid Triggers Bean Validation on payload Validation Groups Different rules for create vs update Cascaded & Cross-Field Nested objects and field dependencies Global Exception Handler Returns structured error responses Service-Layer @Validated Validates method parameters manually ⚠ Missing @Valid on nested collections Empty list passes validation; add @Valid on each element THECODEFORGE.IO
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.

io/thecodeforge/validation/ValidationGroups.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
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;
    }
}
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.

io/thecodeforge/exception/GlobalExceptionHandler.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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) {}
}
Output
// @RequestBody validation failure:
{
"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"
}
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.

io/thecodeforge/validation/PasswordMatchValidator.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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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;
}
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 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
    }
}
Output
// Calling userService.createUser(dtoWithNullUsername) throws:
// 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.

io/thecodeforge/controller/UserController.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
47
48
49
50
51
52
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;
}
Output
// POST /api/users { "email": "not-an-email", "age": 16 }
// 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" } ]
@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 tutorial

import 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 @interface NotDuringMarketHours {
    String message() default "Cannot schedule maintenance during market hours (09:30-16:00 ET)";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class NotDuringMarketHoursValidator
        implements ConstraintValidator<NotDuringMarketHours, String> {

    @Override
    public boolean isValid(String startTime, ConstraintValidatorContext context) {
        if (startTime == null || startTime.isBlank()) {
            return true; // let @NotNull handle nulls
        }
        // Parse "HH:mm" format; hardcoded ET zone for clarity
        String[] 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 hours
        return 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 tutorial

import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import java.util.List;

public class OrderRequest {

    @NotBlank(message = "Customer reference is required")
    private String customerRef;

    // BOTH annotations required: @NotEmpty for the list itself, @Valid for items
    @NotEmpty(message = "At least one order item must be provided")
    @Valid
    private List<@Valid OrderItem> items;

    // Getters and setters omitted for brevity
}

class OrderItem {

    @NotBlank(message = "SKU cannot be blank")
    private String sku;

    private int 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.

ProductController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — java tutorial

@RestController
@RequestMapping("/api/products")
public class ProductController {

    @PostMapping
    public ResponseEntity<?> createProduct(
            @Valid @RequestBody ProductRequest request,
            BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            List<String> errors = bindingResult.getFieldErrors()
                    .stream()
                    .map(e -> e.getField() + ": " + e.getDefaultMessage())
                    .toList();
            return ResponseEntity.badRequest().body(errors);
        }
        // proceed with creation
        return ResponseEntity.ok("Product created");
    }
}
Output
HTTP 400
["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'.

SearchController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — java tutorial

import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Validated
@RestController
@RequestMapping("/api/search")
public class SearchController {

    @GetMapping
    public String search(
            @RequestParam @NotBlank String query,
            @RequestParam @Min(1) int page) {
        return "Results for: " + query + " page " + page;
    }
}
Output
GET /api/search?query=&page=0
HTTP 400
"search.query: must not be blank"
"search.page: must be greater than or equal to 1"
Senior Shortcut:
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.

UserController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorial

@RestController
public class UserController {

    // BAD: primitive silently throws IllegalArgumentException
    @GetMapping("/user")
    public String getBad(@RequestParam int id) {
        return "user " + id;
    }

    // GOOD: wrapper + validation catches missing param gracefully
    @GetMapping("/user/v2")
    public String getGood(@Valid @RequestParam @NotNull @Min(1) Integer id) {
        return "user " + id;
    }
}
Output
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.

UserController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorial

@RestController
public class UserController {

    @PostMapping("/users")
    public ResponseEntity<?> create(@Valid @RequestBody User user, BindingResult result) {
        if (result.hasErrors()) {
            return ResponseEntity.badRequest().body(
                result.getFieldErrors().stream()
                    .map(e -> e.getField() + ": " + e.getDefaultMessage())
                    .toList()
            );
        }
        return ResponseEntity.ok(userService.save(user));
    }
}
Output
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 tutorial

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;

public record UserDTO(
    String name,
    @NotEmpty(message = "At least one address required")
    @Valid
    List<@jakarta.validation.constraints.NotNull Address> addresses
) {}

record Address(
    @NotEmpty String street,
    @NotEmpty String 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 max
public class ValidationOverview {
    public static void main(String[] args) {
        String userInput = "   ";  // blank string
        if (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 max
public class DiscussValidation {
    public static void main(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 max
public class FinalValidation {
    public static void main(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
AspectManual Validation (If-Else)Bean Validation (Annotations)
ReadabilityLow — business logic buried in boilerplate checksHigh — declarative, clean, self-documenting on the DTO
MaintenanceDifficult — validation logic spread across methods and layersEasy — centralized on the DTO, one place to change
ReusabilityLow — logic is copied manually between controllers and servicesHigh — shared across all layers automatically via annotations
StandardizationNone — every developer implements checks differentlyIndustry standard — JSR 380 / Jakarta Validation, consistent everywhere
Error HandlingManual and inconsistent — each endpoint returns different error shapesCentralized via @RestControllerAdvice — consistent structured JSON across all endpoints
TestabilityHarder — validation logic embedded in controller or service methodsEasier — 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
size limits prevent OOM before deserialization; stack trace suppression prevents implementation leakage.
8
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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does Bean Validation slow down my application performance?
02
Can I use multiple annotations on a single field?
03
How do I validate path variables or request parameters?
04
What is the difference between @NotNull, @NotEmpty, and @NotBlank?
05
Can I use Bean Validation outside of Spring MVC, such as in service layers?
06
What is message interpolation and how do I use it in constraint messages?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

13 min read · try the examples if you haven't

Previous
Spring Boot Exception Handling
8 / 21 · Spring Boot
Next
Spring Boot Security Basics