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

30 min

Lesson 2 of 3

1d streak

The 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.

  1. 01Define an @interface annotated with @Constraint(validatedBy = TaxIdValidator.class).
  2. 02Include required boilerplate attributes: message(), groups(), and payload().
  3. 03Implement ConstraintValidator<TaxId, String> and define isValid(String value, ConstraintValidatorContext context).
  4. 04ConstraintValidator instances are managed by Spring, allowing dependency injection if needed.

The architectural antipattern: Database queries inside validators

Do not inject Repositories into ConstraintValidators

Injecting JPA repositories into ConstraintValidator to check for uniqueness or database existence creates hidden database transactions, wastes connection pool capacity during deserialization, and cannot handle race conditions without proper database unique constraints.
Validation ConcernProper LocationWhy
Format / Shape / RegexConstraintValidator (DTO layer)Pure memory check, fast, zero I/O
Nullability / RangeStandard Jakarta (@NotNull, @Min)Deterministic wire contract validation
Uniqueness (e.g. unique email)Database Unique Constraint + ServiceGuarantees concurrency safety via DB unique index
Cross-Entity Business RulesDomain Service methodRequires business transactions and domain events

Custom validator implementation and DTO separation over Groups

TaxId.java
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 {};
}
TaxIdValidator.java
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

Concept A

Validation Groups (@Validated(OnCreate.class))

Concept B

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.

CustomConstraintsLab.java
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
    ) {}
}
Custom constraints require an annotation marked with @Constraint and an implementation of ConstraintValidator.
Validation groups (e.g. OnCreate vs OnUpdate) enforce conditional constraints based on operation lifecycle.
Keep I/O and database lookups out of ConstraintValidators; validation should be pure in-memory logic.

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

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