Skip to content
Home Java Spring Boot Validation with Bean Validation API: The Architect's Guide

Spring Boot Validation with Bean Validation API: The Architect's Guide

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Spring Boot → Topic 8 of 15
Master Spring Boot Validation with the Bean Validation API (JSR 380).
⚙️ Intermediate — basic Java knowledge assumed
In this tutorial, you'll learn
Master Spring Boot Validation with the Bean Validation API (JSR 380).
  • Bean Validation centralizes static format checks on DTOs — no more scattered if-else blocks across controllers and services.
  • @Valid triggers validation on @RequestBody; @Validated triggers validation on path variables and request parameters, and supports groups. Without either, annotations are decorative and silently ignored.
  • MethodArgumentNotValidException covers @RequestBody failures; ConstraintViolationException covers path/param and service-layer failures — handle both in your @RestControllerAdvice.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
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
Production IncidentUser Registration Accepts 2GB Payloads: Missing @Valid Crashes ProductionA user registration endpoint accepted a 2GB JSON payload because @Valid was missing from the controller parameter. The JVM ran out of memory deserializing garbage input before validation could even run.
SymptomProduction 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.
AssumptionThe 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 causeThe @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.
FixAdded @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, nothingAlways set request size limits at the servlet level — Bean Validation runs after deserialization, which is too late for oversized payloadsTest that validation actually triggers by sending invalid data in integration tests — never assume annotations work without verifying with a failing test caseJackson 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 systems
Validation annotations on DTO are completely ignored — invalid data reaches the service layerCheck 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.
API returns 500 Internal Server Error instead of 400 Bad Request on validation failureYou 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.
Update endpoint fails because ID is null — but ID should not be required on createYou 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.
Nested object validation is not triggering — child DTO fields are not validatedAdd @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;
Custom validator is not being called — @Constraint annotation is present but isValid() never executesEnsure 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.
Path variable validation throws 500 instead of 400 — @Min on path variable is not workingPath 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.

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.java · JAVA
1234567891011121314151617181920212223242526272829303132
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.
Mental Model
Bean Validation Mental Model
Bean Validation is a bouncer at the door — it checks every constraint on the DTO before the request is allowed into your business logic. The bouncer only works if you hire them: @Valid is the hiring decision.
  • 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

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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
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.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
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.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
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.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
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
🗂 Manual Validation vs Bean Validation
Production trade-offs at a glance
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

  • Bean Validation centralizes static format checks on DTOs — no more scattered if-else blocks across controllers and services.
  • @Valid triggers validation on @RequestBody; @Validated triggers validation on path variables and request parameters, and supports groups. Without either, annotations are decorative and silently ignored.
  • MethodArgumentNotValidException covers @RequestBody failures; ConstraintViolationException covers path/param and service-layer failures — handle both in your @RestControllerAdvice.
  • Validation groups let you enforce different rules for Create vs Update on the same DTO — use marker interfaces as group identifiers and @Validated(Group.class) in the controller.
  • Cascaded validation requires @Valid on nested fields and collection fields; cross-field validation requires a class-level custom ConstraintValidator with addPropertyNode() for clean field-level error attachment.
  • @NotNull on a primitive type (int, long, boolean) is dead code — use boxed types (Integer, Long, Boolean) when nullability matters.
  • Set spring.servlet.multipart.max-request-size in application.yml and server.error.include-stacktrace=never — size limits prevent OOM before deserialization; stack trace suppression prevents implementation leakage.
  • Keep Bean Validation for static format checks. Dynamic business rules — uniqueness, balance checks, cross-entity consistency — belong in the service layer.

⚠ Common Mistakes to Avoid

    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 Questions on This Topic

  • QWhat is the technical difference between @Valid (Jakarta) and @Validated (Spring) in a Spring Boot environment?Mid-levelReveal
    @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.
  • QExplain how to implement a custom ConstraintValidator for a field-level validation scenario such as a specific zip code format.Mid-levelReveal
    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.
  • QHow does Spring Boot handle cross-field validation where the validity of one field depends on another, such as password and confirmPassword?Mid-levelReveal
    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.
  • QWhich exception is thrown when @RequestBody validation fails versus when path variable or service-layer validation fails? How do you handle both?SeniorReveal
    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.
  • QHow do you validate a List of objects within a wrapper DTO?Mid-levelReveal
    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.
  • QWhy is @NotNull on a primitive field meaningless and what is the correct fix?Mid-levelReveal
    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.

Frequently Asked Questions

Does Bean Validation slow down my application performance?

The overhead is negligible in 99% of applications. Hibernate Validator compiles constraint metadata once at startup and caches it — each subsequent validation call is reflection-optimized and fast. The microsecond cost of validating a DTO is irrelevant compared to the cost of processing malformed data: failed database transactions, corrupted records, debugging time, and incident response. If validation performance is ever a measurable concern, the problem is almost certainly elsewhere in the stack.

Can I use multiple annotations on a single field?

Yes. You can stack annotations freely: @NotBlank @Size(min=3, max=50) @Pattern(regexp="^[a-zA-Z0-9_]+$") on a single field. All constraints are evaluated independently and any that fail are reported together. They are evaluated in an undefined order — never write constraints that depend on each other passing first. Use @NotBlank before size or pattern checks conceptually, but the engine does not guarantee that order.

How do I validate path variables or request parameters?

Add @Validated at the class level on the controller — this activates Spring's AOP-based method parameter validation. Then annotate the method parameters directly with constraint annotations: @PathVariable @Min(1) Long id or @RequestParam @NotBlank String username. @Valid alone does not work for path variables and request parameters — it only activates validation on @RequestBody. Also ensure you have a @ExceptionHandler(ConstraintViolationException.class) in your GlobalExceptionHandler, since path variable failures throw ConstraintViolationException, not MethodArgumentNotValidException.

What is the difference between @NotNull, @NotEmpty, and @NotBlank?

@NotNull rejects null values only — an empty string ("") passes. @NotEmpty rejects null and zero-length strings or empty collections — but a string of only whitespace (" ") passes. @NotBlank rejects null, empty, and whitespace-only strings — it is the strictest option for String fields. The practical guide: use @NotBlank for any String that must contain meaningful content (username, email, description). Use @NotEmpty for collections and arrays that must have at least one element. Use @NotNull for non-String reference types and boxed numerics where any value including zero is valid but null is not.

Can I use Bean Validation outside of Spring MVC, such as in service layers?

Yes. Add @Validated at the class level on your @Service bean. Spring's MethodValidationPostProcessor (automatically registered when spring-boot-starter-validation is on the classpath) wraps the bean in an AOP proxy. Constraint annotations on method parameters are evaluated when the method is called through the proxy. Use @Valid on DTO parameters to trigger full object validation and constraint annotations directly on scalar parameters. Failures throw ConstraintViolationException. One important caveat: self-invocation within the same bean bypasses the proxy and skips validation — calls must cross a bean boundary for the proxy to intercept them.

What is message interpolation and how do I use it in constraint messages?

Hibernate Validator supports message interpolation tokens that are replaced with actual annotation attribute values at runtime. In your constraint message, wrap attribute names in curly braces: @Size(min = 3, max = 20, message = "Username must be between {min} and {max} characters"). At validation time, {min} is replaced with 3 and {max} with 20. This avoids hardcoding numbers in the message that drift out of sync when you change the annotation attributes. Standard tokens work for any annotation attribute: {value} for @Min and @Max, {min} and {max} for @Size, {regexp} for @Pattern. Custom constraint annotations support the same mechanism for any attribute you define.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSpring Boot Exception HandlingNext →Spring Boot Security Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged