Intermediate · Harden
Write Custom Constraints Without Hiding Business Logic
Create reusable cross-field validators and understand when validation groups become a maintenance cost.
Primary source: Jakarta Bean Validation Specification: ConstraintValidator
Lesson 2 of 3
1d streakThe Architectural Question
Validators that query the database turn declarative constraints into hidden transactions with unpredictable latency and test setup.
What this guide proves
- Keep validators deterministic and side-effect free
- Attach field paths clients can act on
- Prefer distinct command types when create and update rules diverge
Building custom declarative constraints with ConstraintValidator
When standard annotations (@NotNull, @Size, @Pattern) cannot express domain format constraints, create a custom constraint by defining a runtime annotation coupled with a ConstraintValidator implementation.
- 01Define an @interface annotated with @Constraint(validatedBy = TaxIdValidator.class).
- 02Include required boilerplate attributes: message(), groups(), and payload().
- 03Implement ConstraintValidator<TaxId, String> and define isValid(String value, ConstraintValidatorContext context).
- 04ConstraintValidator instances are managed by Spring, allowing dependency injection if needed.
The architectural antipattern: Database queries inside validators
Do not inject Repositories into ConstraintValidators
| Validation Concern | Proper Location | Why |
|---|---|---|
| Format / Shape / Regex | ConstraintValidator (DTO layer) | Pure memory check, fast, zero I/O |
| Nullability / Range | Standard Jakarta (@NotNull, @Min) | Deterministic wire contract validation |
| Uniqueness (e.g. unique email) | Database Unique Constraint + Service | Guarantees concurrency safety via DB unique index |
| Cross-Entity Business Rules | Domain Service method | Requires business transactions and domain events |
Custom validator implementation and DTO separation over Groups
package com.example.common.validation;
import jakarta.validation.*;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = TaxIdValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface TaxId {
String message() default "Invalid Tax ID format (expected XX-XXXXXXX)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}package com.example.common.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class TaxIdValidator implements ConstraintValidator<TaxId, String> {
private static final Pattern PATTERN = Pattern.compile("^\\d{2}-\\d{7}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) return true; // Let @NotBlank handle presence
return PATTERN.matcher(value).matches();
}
}Validation Groups vs Separate DTOs: Why separate DTOs win
Architectural Boundary · Do Not Confuse
Validation Groups (@Validated(OnCreate.class))
Separate DTOs (CreateOrderRequest vs UpdateOrderRequest)
Validation groups introduce marker interfaces and conditional checks into a single class. They are brittle and hard to read. In contrast, creating distinct DTOs for creation and update operations creates explicit, self-documenting wire contracts that require zero marker gymnastics.
Active Recall Checkpoint
Why should a custom format validator return true when the incoming value is null?
Hint: Consider separation of responsibilities between @NotNull and format checks.
Show answer & explanation
A format validator should focus exclusively on format validation when a value is present. If the field is required, it should be annotated separately with @NotNull or @NotBlank. Combining null-checking into format validators breaks composability.
Design criteria for custom constraints
Use it when
Use custom ConstraintValidators for self-contained, stateless syntax and format validation.
Reconsider when
Avoid Validation Groups on complex schemas; author dedicated Create and Update request records instead.
Working Implementation
Code Walkthrough
Validate that a booking end time is after its start time.
package com.example.validation;
import jakarta.validation.*;
import java.lang.annotation.*;
/**
* Custom constraint validator with Jakarta Validation Groups.
*/
public class CustomConstraintsLab {
// Validation groups
public interface OnCreate {}
public interface OnUpdate {}
@Documented
@Constraint(validatedBy = TaxIdValidator.class)
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidTaxId {
String message() default "Invalid Tax ID format (expected TAX-XXXX)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public static class TaxIdValidator implements ConstraintValidator<ValidTaxId, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // Null handling delegated to @NotNull
return value.matches("^TAX-\\d{4,8}$");
}
}
public record BusinessAccountDto(
@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
String id,
@NotNull(groups = { OnCreate.class, OnUpdate.class })
@ValidTaxId(groups = { OnCreate.class, OnUpdate.class })
String taxId
) {}
}Synthesis & Production Verification
Production Acceptance Criteria
- Implement a @Constraint validator for a cross-field date range
- Report messages carrying field paths clients can act on
- Decide between validation groups and separate command types for create/update divergence
Key Takeaway
Custom ConstraintValidators provide reusable format checks; separate dedicated request DTOs should be preferred over complex validation groups.
Architectural & Technical Review
Core Interview Questions
Q01How do you define a custom Jakarta validation constraint?Show answer
Create an @interface annotated with @Constraint(validatedBy = ...) and implement ConstraintValidator<A, T> with an isValid() method.
Q02Why is injecting database repositories into ConstraintValidators an antipattern?Show answer
It executes hidden I/O during deserialization, consumes connection pool capacity, and cannot safely guarantee concurrency without DB constraints.
Q03Why are separate DTOs preferable to Validation Groups?Show answer
Separate DTOs (e.g. CreateRequest vs UpdateRequest) make wire contracts explicit and self-documenting without brittle marker interfaces.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Failing null checks inside format validators — Format validators should return true on null/blank values to allow @NotNull or @NotBlank to handle presence independently.
- Pitfall: Using validation groups for divergent API payloads — Conditional group validation becomes unmaintainable as payload schemas diverge; create distinct request records instead.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources