How do you validate a cross-field rule and when should validation live in the service layer instead of the controller?
answer
- cross-field = class-level @Constraint on TYPE + ConstraintValidator
- failure = global ObjectError (or re-node to field)
- isValid returns true for null; @NotNull owns presence
- shape at edge, invariants in service/@Transactional
- DB uniqueness -> service + DB constraint, not a validator (TOCTOU)
basics
~20 sCross-field rules use a class-level constraint (a custom annotation + ConstraintValidator on the whole object), surfacing as a global error. Input-shape validation belongs at the controller edge; business rules needing DB/state belong in the service.
solid answer
~50 sA rule spanning multiple fields (password == confirmPassword, start < end) can't be a single-field annotation, so you write a **class-level constraint**: a custom annotation with `@Target(TYPE)` backed by a `ConstraintValidator` that reads the whole object. Failures become `ObjectError`s (`getGlobalErrors()`), or you can bind them to a field via `ConstraintValidatorContext.buildConstraintViolationWithTemplate(...).addPropertyNode(...)`. Bean Validation at the controller is for **syntactic/shape** concerns — required, size, format — and should stay fast and stateless. **Stateful business rules** (unique email, sufficient balance, entitlement checks) need repository/service access and belong in the **service layer**, thrown as domain exceptions, because they require transactions, may race, and shouldn't be reachable only through the web layer. You can inject Spring beans into a ConstraintValidator, but pulling the DB into annotation validation couples the domain to the web edge and hides transactional concerns — prefer explicit service checks for those.
code
java · 25 lines@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "start must be before end";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, BookingDto> {
@Override
public boolean isValid(BookingDto b, ConstraintValidatorContext ctx) {
if (b == null || b.getStart() == null || b.getEnd() == null) {
return true; // presence handled by @NotNull
}
return b.getStart().isBefore(b.getEnd());
}
}
@ValidDateRange
public class BookingDto {
@NotNull LocalDate start;
@NotNull LocalDate end;
}go deeper
Know cross-field needs a custom class-level annotation, not per-field.
Implement a ConstraintValidator and know failures are global by default.
Re-node violations to fields, null-guard correctly, and split shape vs business validation.
Draw the architectural boundary: stateless shape validation at the edge, stateful invariants in the transactional service with DB constraints as the final guard; avoid coupling domain rules to web DTOs.
## Cross-field (class-level) constraints Single-field annotations can't compare two fields. The standard mechanism is a **class-level constraint**: ```java @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = PasswordsMatchValidator.class) public @interface PasswordsMatch { String message() default "passwords do not match"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, SignupForm> { @Override public boolean isValid(SignupForm f, ConstraintValidatorContext ctx) { if (f == null) return true; // null handled by @NotNull elsewhere boolean ok = Objects.equals(f.getPassword(), f.getConfirm()); if (!ok) { ctx.disableDefaultConstraintViolation(); ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate()) .addPropertyNode("confirm") // attach to a field instead of global .addConstraintViolation(); } return ok; } } ``` Annotate the class: `@PasswordsMatch public class SignupForm { ... }`. By default the violation is a **global** error (`ObjectError`, seen via `getGlobalErrors()`); the `addPropertyNode` trick re-targets it to a `FieldError`. ## ConstraintValidator lifecycle - `initialize(annotation)` runs once per constraint to capture attributes. - `isValid(value, context)` runs per validation; **return true for null** and let `@NotNull` own presence, to keep constraints composable. - Validators can be **Spring-managed** — Spring's `LocalValidatorFactoryBean` (auto-configured) makes them injectable, so you *can* `@Autowired` a bean. That capability is the temptation described next. ## Where validation belongs — the architectural call **Bean Validation at the controller edge** = *input shape / syntactic* validation: - required, length, format, range, cross-field consistency of the payload itself. - Stateless, fast, deterministic, no I/O. **Service-layer validation** = *business/domain* rules that need state: - uniqueness ("email already taken"), balance sufficiency, authorization/entitlement, workflow state ("order already shipped"). - These require repository/DB access, run inside a `@Transactional` boundary, and can **race** (a uniqueness check then insert must handle the DB unique constraint anyway). ### Why not push DB checks into a ConstraintValidator? You technically can inject a repository into a validator, but: 1. It couples the **domain invariant** to the **web intake** — the rule is only enforced when a request goes through that annotated DTO, not when the service is called from a scheduler, message consumer, or test. 2. It hides **transactional** semantics; the query runs outside the service's transaction, widening the TOCTOU race window. 3. It muddies error handling: a business conflict becomes a 400 validation error rather than a 409 domain conflict with proper semantics. So: **shape at the edge, invariants in the domain/service**, and rely on the DB unique/constraint as the final arbiter for races. Surface service failures as domain exceptions mapped by `@RestControllerAdvice` to appropriate status codes (e.g., 409 Conflict). ## Gotchas - Class-level constraint failures are **global** unless you re-node them to a field. - Don't duplicate the same rule in both layers unless intentionally defense-in-depth. - Cross-field validators must null-guard the object and individual fields. - Group selection applies to class-level constraints too (`groups` attribute). ## When to use which - Payload internal consistency, format, presence → **Bean Validation / class-level constraint** at controller. - Anything requiring current system state or a transaction → **service layer**, with the persistence constraint as the ultimate guard.
- Why is a DB uniqueness check as a @ConstraintValidator considered problematic?It couples a domain invariant to the web-layer DTO (bypassed by other entry points), runs outside the service transaction widening the race window, and turns a 409-style conflict into a 400. Enforce uniqueness in the service plus a DB unique constraint.
- By default, where does a class-level constraint failure show up in the BindingResult?As a global error via getGlobalErrors() (an ObjectError). You can re-attach it to a specific field using ConstraintValidatorContext.buildConstraintViolationWithTemplate(...).addPropertyNode(field).
saying these in an interview costs you the question
- Trying to compare two fields with a single-field annotation
- Putting uniqueness/business-state checks in Bean Validation and calling it done (ignoring TOCTOU/transactions)
- Forgetting class-level failures are global, not field errors
- Returning false for null in isValid, double-counting with @NotNull