skip to content

How do you validate a cross-field rule and when should validation live in the service layer instead of the controller?

level: principalimportance: should knowfreq 35%

answer

  1. cross-field = class-level @Constraint on TYPE + ConstraintValidator
  2. failure = global ObjectError (or re-node to field)
  3. isValid returns true for null; @NotNull owns presence
  4. shape at edge, invariants in service/@Transactional
  5. DB uniqueness -> service + DB constraint, not a validator (TOCTOU)

basics

~20 s

Cross-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 s

A 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
java
@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

for a junior

Know cross-field needs a custom class-level annotation, not per-field.

for a middle

Implement a ConstraintValidator and know failures are global by default.

for a senior

Re-node violations to fields, null-guard correctly, and split shape vs business validation.

for a principal

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

context