Custom Validator

When Built-in Constraints Are Not Enough

ShopEase product codes (SKUs) look like ELE-1234: three capital letters for the category, a dash and four digits. Write the rule once as a custom constraint and reuse it everywhere. First test the logic in plain Java:

Example

import java.util.List;

public class Main {
    static boolean isValidSku(String sku) {
        if (sku == null) return true;                 // like built-in constraints: use @NotNull for required
        if (sku.length() != 8 || sku.charAt(3) != '-') return false;
        for (int i = 0; i < 3; i++) if (!Character.isUpperCase(sku.charAt(i))) return false;
        for (int i = 4; i < 8; i++) if (!Character.isDigit(sku.charAt(i))) return false;
        return true;
    }

    public static void main(String[] args) {
        List<String> inputs = List.of("ELE-1234", "BOO-0042", "ele-1234", "ELE1234", "EL-12345", "ELE-12A4");
        for (String s : inputs) System.out.printf("%-9s -> %s%n", s, isValidSku(s) ? "valid" : "invalid");
        System.out.println("null      -> " + (isValidSku(null) ? "valid" : "invalid"));
    }
}

Output

ELE-1234  -> valid
BOO-0042  -> valid
ele-1234  -> invalid
ELE1234   -> invalid
EL-12345  -> invalid
ELE-12A4  -> invalid
null      -> valid

The Annotation and the Validator

import jakarta.validation.*;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
    String message() default "must look like ABC-1234";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class SkuValidator implements ConstraintValidator<ValidSku, String> {
    @Override
    public boolean isValid(String sku, ConstraintValidatorContext context) {
        return sku == null || sku.matches("[A-Z]{3}-[0-9]{4}");
    }
}

// Use it like any built-in constraint
public record CreateProductRequest(@NotNull @ValidSku String sku, @NotBlank String name, ...) {}

Tip: A ConstraintValidator is created by Spring, so you can inject beans into it (e.g. a repository to check that a category exists).

Class-Level Constraint

Some rules involve two fields, e.g. "sale price must be lower than price". Put the constraint on the class (record) and check the whole object:

@Constraint(validatedBy = SalePriceValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface SalePriceBelowPrice {
    String message() default "salePrice must be lower than price";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class SalePriceValidator implements ConstraintValidator<SalePriceBelowPrice, PriceRequest> {
    public boolean isValid(PriceRequest r, ConstraintValidatorContext ctx) {
        return r.salePrice() == null || r.price() == null || r.salePrice().compareTo(r.price()) < 0;
    }
}

@SalePriceBelowPrice
public record PriceRequest(@NotNull BigDecimal price, BigDecimal salePrice) {}

Exercise: Write a @IndianPincode constraint: exactly 6 digits, first digit not 0.

Show answer
public class PincodeValidator implements ConstraintValidator<IndianPincode, String> {
    public boolean isValid(String pin, ConstraintValidatorContext ctx) {
        return pin == null || pin.matches("[1-9][0-9]{5}");
    }
}