← Back to the section

The standard annotations — @NotNull, @Size, @Email — are enough for simple cases. When the logic gets more complex, you write your own constraints. Let's look at how to do that and how to configure clear error texts.

Why custom constraints are needed

Built-in annotations check a single field against a simple criterion. They cannot:

  • compare a value against the database ("this login is already taken");
  • verify that two fields match ("password and confirmation must be equal");
  • apply a domain-specific business rule ("the discount cannot exceed the price").

For this you create a custom constraint — a pair consisting of an annotation and a validator class.

How a custom constraint is built

You need two classes: an annotation and a validator.

// 1. Annotation
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPhone {
    String message() default "Invalid phone number format";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

The three attributes — message, groups, payload — are mandatory for any constraint.

// 2. Validator
public class PhoneValidator implements ConstraintValidator<ValidPhone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null) return true; // null is checked by @NotNull
        return value.matches("\\+7\\d{10}");
    }
}

A short formula: the annotation describes the contract, the validator implements it.

It is applied like any regular annotation:

public record CreateUserRequest(
    @NotBlank String name,
    @ValidPhone String phone
) {}

Cross-field validation

You cannot check that two fields are consistent with each other at the level of an individual field — you need a constraint at the class level.

@Documented
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordsMatch {
    String message() default "Passwords do not match";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator
        implements ConstraintValidator<PasswordsMatch, ChangePasswordRequest> {

    @Override
    public boolean isValid(ChangePasswordRequest req, ConstraintValidatorContext ctx) {
        if (req.password() == null) return true;
        return req.password().equals(req.confirmPassword());
    }
}
@PasswordsMatch
public record ChangePasswordRequest(
    @NotBlank String password,
    @NotBlank String confirmPassword
) {}

On violation the error is bound to the object, not to a field. To bind it to a specific field:

ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
   .addPropertyNode("confirmPassword")
   .addConstraintViolation();
return false;

Error messages

The error text is set in the message attribute. You can write it directly in the annotation:

@ValidPhone(message = "Phone number must start with +7 and contain 11 digits")
String phone;

Or use a key from the messages file in curly braces:

String message() default "{validation.phone.invalid}";

Then the text is taken from ValidationMessages.properties — the standard Bean Validation file.

Internationalization via messages.properties

Create the file src/main/resources/ValidationMessages.properties:

validation.phone.invalid=Invalid phone number format
validation.passwords.mismatch=Passwords do not match

Or in UTF-8 directly — Spring Boot 3 supports this without extra configuration:

validation.phone.invalid=Invalid phone number format
validation.passwords.mismatch=Passwords do not match

Into the message you can substitute annotation attributes via ${attribute} or a value via {javax.validation.constraints.Size.message}. For most cases a fixed text with a key is enough.

When it's better to check in the service

A custom constraint is the right tool if the rule is:

  • purely syntactic (format, range, data structure);
  • reused in several places;
  • does not require access to the database or an external service.

If the check requires a query to the database ("login already taken", "category exists"), it's better to move it into the service. Injecting a repository into a ConstraintValidator via @Autowired is technically possible, but it leads to non-obvious dependencies and complicates testing. The rule: a constraint is for structural correctness, the service is for business invariants involving data.

In short

  • A custom constraint is a pair: an annotation with @Constraint and a class implementing ConstraintValidator<A, T>.
  • Three mandatory attributes on any constraint: message, groups, payload.
  • Cross-field validation is done with a class-level constraint (@Target(ElementType.TYPE)).
  • Error texts are moved into ValidationMessages.properties and referenced via {key}.
  • Checks that access the database or external services go in the service layer, not in the validator.
  • Bean Validation: @NotNull, @Size, @Valid and groups — the standard annotations and how to trigger validation.
  • Where to validate: controller, service, or domain — choosing the right layer.
  • REST API errors — how to turn a MethodArgumentNotValidException into a structured response.
  • Validation standards R-VLD-* — rules for team projects.