DEV Community

Aquib Javed
Aquib Javed

Posted on

Mastering Custom Validation Annotations in Spring Boot 3: Beyond @NotNull

Beyond @NotNull: Creating Custom Validation Annotations in Spring Boot 3

TL;DR: Standard Jakarta Bean Validation annotations often fail to capture complex, multi-field business rules. This guide explains how to implement the ConstraintValidator interface to create reusable, class-level custom annotations in Spring Boot. Anyone who wants to learn how to create their own custom annotation and handle business specific-logic, must go through the blog.

Audience: Java developers familiar with Spring Boot and basic REST API development.

Scope: This guide covers the creation of a class-level validation annotation (@OfficialInviteCode). It does not cover simple field-level regex validation or frontend integration.

The Limitations of Standard Annotations

Imagine you have these business rules for a VIP event:

  • Invite Code: Must start with "VIP-".
  • Age: Guests must be older than 35.
  • Email: Must end with a .com domain.
  • Name: Must be uppercase (business requirement).

Standard annotations like @Email or @Min handle these rules individually, but they often struggle with interdependent logic or specific business formats.

The Imperative Approach (Anti-Pattern)

Developers often mix validation logic directly into the service or controller layer:

// Avoid this approach
if (!dto.getInviteCode().startsWith("VIP-")) {
    throw new ValidationException("Invalid Code");
}

Enter fullscreen mode Exit fullscreen mode

This approach creates code duplication and makes unit testing difficult.
The Declarative Approach (Best Practice)
A better solution uses a custom annotation to centralize logic and separate concerns:

@OfficialInviteCode
private String inviteCode;

Enter fullscreen mode Exit fullscreen mode

This guide demonstrates how to implement this declaratively using class-level validation.
Implementation Guide
Follow this order of operations to implement custom validation:
Define the DTO.
Define the custom annotation interface.
Implement the ConstraintValidator.
Apply the annotation to the DTO.
Create the Controller.
Add a Global Exception Handler.

  1. Create the DTO Class Define the data structure and apply standard constraints where applicable.
public class GuestRegistrationDto {
    @NotNull
    private String name;

    @Min(value = 35, message = "Guest must be above 35")
    private int age;

    @Email
    @NotBlank
    private String email;

    private String inviteCode;

    // Getters and Setters
    public String getName() { return name; }
    public String getEmail() { return email; }
    public String getInviteCode() { return inviteCode; }
    public int getAge() { return age; }
}

Enter fullscreen mode Exit fullscreen mode
  1. Define the Custom Annotation Create the @OfficialInviteCode interface. This interface acts as the contract for your validation logic.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OfficialInviteCodeValidator.class)
public @interface OfficialInviteCode {

    String message() default "Invalid invite code";
    Class<?>[] groups() default {};
    Class<? extends jakarta.validation.Payload>[] payload() default {};
}
Enter fullscreen mode Exit fullscreen mode

Key Concepts:
@Target(ElementType.TYPE): Indicates that you apply this annotation to the class level, allowing access to all fields within the class.
@Retention(RetentionPolicy.RUNTIME): Ensures the annotation remains available to the JVM during execution for Spring's reflection mechanisms.
@Constraint(validatedBy = ...): Links this annotation to the OfficialInviteCodeValidator class, which contains the logic.
The Jakarta API Contract:
The API mandates three attributes for every validation annotation:
message(): The default error text.
groups(): Enables validation groups (e.g., validate only on "Update" operations).
payload(): Attaches custom metadata, such as severity levels.
3.Implement the ConstraintValidator
Override the isValid method to implement your specific business rules.

public class OfficialInviteCodeValidator implements ConstraintValidator<OfficialInviteCode, GuestRegistrationDto> {

    @Override
    public boolean isValid(GuestRegistrationDto dto, ConstraintValidatorContext constraintValidatorContext) {
        if (dto == null) return true;

        String name = dto.getName();
        String email = dto.getEmail();
        String code = dto.getInviteCode();
        int age = dto.getAge();

        // Business Logic Validation
        if (name != null && !name.equals(name.toUpperCase())) {
            return false; 
        }
        if (email != null && !email.endsWith(".com")) {
            return false;
        }
        if (code != null && !code.startsWith("VIP-")) {
            return false;
        }
        if (age >= 35) {
            return true;         
        }
        return false;
    }
}

Enter fullscreen mode Exit fullscreen mode
  1. Annotate the DTO Apply your new annotation to the class definition. The DTO now expresses both built-in constraints and your custom logic.
@OfficialInviteCode
public class GuestRegistrationDto {
    // fields...
}

Enter fullscreen mode Exit fullscreen mode

Now the DTO expresses all rules: built-in constraints + your custom constraint in one place.

  1. Create the REST Controller Use the @valid annotation to trigger the validation logic before the method executes.
@RestController
public class GuestController {

    @PostMapping("/invite")
    public ResponseEntity<?> registerGuest(@Valid @RequestBody GuestRegistrationDto dto) {
        return ResponseEntity.ok("registered");
    }
}

Enter fullscreen mode Exit fullscreen mode
  1. Handle Exceptions Globally Create a @RestControllerAdvice class to catch MethodArgumentNotValidException and return a clean JSON response.
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationException(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .orElse("Invalid request");

        Map<String, String> body = new HashMap<>();
        body.put("error", message);
        return body;
    }
}

Enter fullscreen mode Exit fullscreen mode

Architecture & Best Practices
To master custom validation, understand how Spring manages the lifecycle of validator classes.
Leverage Dependency Injection
Spring manages your ConstraintValidator implementation as a Bean. This allows you to inject services or repositories directly into the validator.
Use Case: Verify data existence in the database (e.g., checking if a coupon code is valid) rather than just checking format.

public class CouponValidator implements ConstraintValidator<ValidCoupon, String> {

    @Autowired
    private CouponRepository couponRepository; // Spring injects this dependency

    @Override
    public boolean isValid(String code, ConstraintValidatorContext context) {
        return couponRepository.existsByCode(code);
    }
}

Enter fullscreen mode Exit fullscreen mode

Ensure Thread Safety
Spring treats Validator classes as Singletons. The container creates one instance and reuses it for every request.
The Risk: Storing request-specific data in instance variables causes race conditions.
The Solution: Keep validators stateless. Perform all logic using only the local variables passed into the isValid() method.

Conclusion
Spring Boot’s validation framework extends well beyond simple null checks. By using ConstraintValidator, you create robust, reusable, and centralized business rules that keep your controllers and services clean.

Top comments (0)