Building Custom Bean Validation Validators in Spring Boot

Hello.
These are some notes on something that came up while preparing for a technical interview: writing custom Bean Validation validators in Spring Boot, instead of relying only on the standard annotations (@NotNull, @Size, @Email, and so on).
Standard annotations cover a lot, but they don't cover business-specific format rules or cross-field logic. Encapsulating that kind of validation properly, instead of scattering if checks across the service layer, is one of those details that separates a senior-level implementation from an average one.
What You Need
Two things: the annotation (the label you put on a field) and the validator (the actual logic behind it).
1. The Annotation
This is where you define what your validation is called and what error message it produces.
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CuitValidator.class) // links the annotation to its logic
public @interface ValidCuit {
String message() default "Invalid CUIT format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
(CUIT is Argentina's tax identification number — the example is local, but the pattern applies to any structured identifier: VAT numbers, national IDs, IBANs, whatever format your domain needs to enforce.)
2. The Validator (the Logic)
You create a class implementing ConstraintValidator. The useful part here: Spring lets you inject dependencies into this class — so if validating the field requires hitting a repository or a service (for example, a uniqueness check against the database), you can do that directly inside the validator.
public class CuitValidator implements ConstraintValidator<ValidCuit, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return false;
// Your actual format/algorithm check goes here
return value.matches("\\d{2}-\\d{8}-\\d{1}");
}
}
3. Using It in Your DTO
Once it's defined, you use it exactly like any other Bean Validation annotation:
public class CustomerDto {
@ValidCuit
private String cuit;
}
When Should You Write Your Own?
In a backend-heavy context (I've worked mostly in banking-style backends), this pattern earns its keep in a few recurring situations:
- Domain-specific formats: tax IDs, national IDs, account numbers with check digits.
- Cross-field logic: for example, making sure an "end date" is actually after a "start date".
- Uniqueness checks: verifying an email doesn't already exist before attempting an insert, by injecting the repository into the validator itself.
Interview Tip: Performance
If the performance question comes up: custom validators are efficient because Spring caches them — the constraint metadata isn't re-resolved on every request. The real payoff, though, is architectural: your Controller and Service stay completely free of format-validation logic, respecting Single Responsibility. The service layer can assume the data it receives is already clean.
Suggested Project Structure
Keeping validation logic in its own package, separate from the model and the controllers, is a small structural decision that pays off as the codebase grows:
src/main/java/com/company/project/
│
├── dto/
│ └── PaymentDto.java <-- uses the @ValidCuit annotation
│
├── validation/ <-- dedicated package
│ ├── ValidCuit.java <-- the interface (annotation)
│ └── CuitValidator.java <-- the logic (implementation)
│
└── exceptions/
└── GlobalExceptionHandler.java <-- where a failed validation is caught
Naming Convention
To keep this readable across a codebase with many custom validators, I stick to two simple rules:
- The annotation starts with an adjective or verb describing the desired state —
@ValidCuit,@NotBlankCustom,@UniqueEmail. - The validator reuses the annotation's name with a Validator suffix —
CuitValidator,UniqueEmailValidator.
The Full Validation Flow
When a client sends a JSON payload to the controller, here's what actually happens, step by step:
- Spring intercepts the request at the controller, because of
@Valid. - It looks up the
CuitValidatorlogic registered for that field's annotation. - It executes
isValid(). - It decides: if the result is
false, the request is rejected right there — the exception is thrown before execution ever reaches the service layer.
That last point is the actual payoff: your business logic in the Service layer can assume the data it receives is already valid, because the validator already did that job at the door.
Conclusion
Custom Bean Validation validators are a small piece of API surface (one annotation, one class implementing ConstraintValidator), but they buy a lot: domain-specific rules get enforced consistently, close to the field they apply to, without leaking format checks into the service layer. For anything beyond what @NotNull/@Size/@Email already cover, this is the pattern to reach for.
Thanks for reading.