Java

Spring Boot 3 Validation Pipeline: Custom Constraints, Groups, and Graceful API Errors

Build a robust Spring Boot 3 validation pipeline with custom constraints, groups, and clean API errors. Learn defensible patterns now.

Spring Boot 3 Validation Pipeline: Custom Constraints, Groups, and Graceful API Errors

I was staring at yet another stack trace from production. An invalid email had slipped through, a password field was left blank, and a date of birth from 1783 was accepted without complaint. Our validation logic was scattered across thirty controllers, inconsistent, and never enforced at the database layer. That day, I decided it was time to build a validation pipeline that could be trusted end to end. Spring Boot 3, with Bean Validation 3.0 and Hibernate Validator, gave me the tools. But the documentation treats validation like a trivial add‑on. It is not. When your API processes thousands of signups per minute, validation is your first line of defense. Let me show you how to make it defensible, testable, and even graceful.

Why do developers usually treat @NotNull and @Email as the whole story? Because the framework makes it easy to stop there. The real challenge begins when you need cross‑field logic, conditional rules, and ordered checks. Consider a user registration form: the password confirmation field must match the password, but only on creation, never on update. The phone number is optional, but if provided, it must match a specific country code. The date of birth must be at least 18 years ago, and the email domain must be an approved corporate list. That is not simple annotation work anymore.

Bean Validation 3.0 solves this with three pillars: custom constraints, validation groups, and group sequences. I will walk you through each one with code you can steal for your next project.

First, build a custom annotation for a strong password. The definition looks like any other constraint:

@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {
    String message() default "Password must have 8+ chars, upper, lower, digit, and special char";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

The validator implements ConstraintValidator:

public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true; // let @NotNull handle missing value
        return value.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=]).{8,}$");
    }
}

That is enough to make a password field bulletproof. But what about checking that the confirmation matches the password? That is a class-level constraint. Place the annotation on the DTO class itself:

@Target({TYPE})
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
    String message() default "Passwords do not match";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

And the validator accesses the whole object:

public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, UserRegistrationRequest> {
    @Override
    public boolean isValid(UserRegistrationRequest request, ConstraintValidatorContext context) {
        if (request.getPassword() == null || request.getConfirmPassword() == null) return true;
        return request.getPassword().equals(request.getConfirmPassword());
    }
}

Now, how do you tell validation to run this class-level check only when creating a new user, not when updating? That is where validation groups save the day. Define marker interfaces: OnCreate, OnUpdate. Attach them to your constraint annotations using the groups attribute. Then in your controller method, use @Validated(OnCreate.class) for the POST endpoint and @Validated(OnUpdate.class) for the PUT endpoint.

@PostMapping("/users")
public ResponseEntity<?> create(@Validated(OnCreate.class) @RequestBody UserRegistrationRequest request) {
    // ...
}

The @Null(groups = OnUpdate.class) on the password field ensures the password is ignored during updates. The @PasswordMatch(groups = OnCreate.class) only runs for creation. Simple, yet I rarely see it used in production code.

What about the order of checks? Suppose you want to verify the username format first, then the email, then the password strength. You can define a group sequence:

@GroupSequence({UsernameChecks.class, EmailChecks.class, PasswordChecks.class})
public interface CreateUserSequence {}

Then use @Validated(CreateUserSequence.class). If username fails, email checks never run. That prevents wasteful validation and gives the user a clear error for the first failure.

But validation does not end at the controller. I always adopt a layered approach. In the service layer, inject the jakarta.validation.Validator and run programmatic validation for business rules:

@Service
public class UserService {
    private final Validator validator;

    public UserService(Validator validator) {
        this.validator = validator;
    }

    public void save(User user) {
        Set<ConstraintViolation<User>> violations = validator.validate(user, UpdateChecks.class);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
        // persist
    }
}

This works for legacy objects or when you need to validate after partial changes. In the persistence layer, Spring Data JPA can also fire validation before flush if you enable the bean validation integration (it is off by default in Spring Boot). Set spring.jpa.properties.javax.persistence.validation.mode=AUTO.

Now, transform those ugly ConstraintViolationException responses into something your frontend can consume. Use a @RestControllerAdvice:

@RestControllerAdvice
public class ValidationExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, List<String>>> handle(MethodArgumentNotValidException ex) {
        Map<String, List<String>> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error -> 
            errors.computeIfAbsent(error.getField(), k -> new ArrayList<>()).add(error.getDefaultMessage())
        );
        return ResponseEntity.badRequest().body(errors);
    }
}

I also add a custom @AuditConstraint that logs every failed validation for security monitoring. That is where payloads come in: attach a severity or audit flag to the constraint, and the ConstraintViolation gives you access to the payload.

Have you ever accidentally validated the same field twice? It happens. The framework merges constraints, so you can end up with multiple errors for the same field. I use unit tests with ValidatorFactory and Validator to verify that each object passes or fails exactly as expected. Hibernate Validator provides a testing utility that makes this straightforward:

@Test
void strongPasswordShouldFailForWeakPassword() {
    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
    UserRegistrationRequest request = new UserRegistrationRequest();
    request.setPassword("abc");
    Set<ConstraintViolation<UserRegistrationRequest>> violations = validator.validateProperty(request, "password");
    assertThat(violations).hasSize(1);
}

Testing validation logic in isolation prevents regressions when the rules change. I update the tests first, then the validator code.

The entire pipeline—from custom annotations to sequence groups to programmatic validation—forms a type‑safe, multi‑step system that guards your data at every layer. It took me a weekend to set up, but it saved me months of debugging production issues.

If you found this approach useful, please like, share, and comment below with your validation horror stories. I read every comment and often incorporate reader feedback into future articles. Now, go make your validation bulletproof.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Similar Posts

Keep Reading