The tools of validation: Bean validation and custom constraints

In the first post of this series, we covered the cost of scattered validation logic. In the second post, we defined the three layers of validation: Boundary, domain, and persistence.

Now in this third post, we’ll dive into the tools that make each layer work: Jakarta Bean Validation, Spring’s validation support, and custom constraints.


Jakarta bean validation: the foundation #

Jakarta Bean Validation (JSR 380) provides a standardized way to validate objects using annotations. It’s the backbone of validation in Spring applications.

Below is a table of the constraints used in this post. The full list can be found here:

Annotation Purpose Example
@NotNull Field must not be null @NotNull Integer age
@NotBlank String must not be null, empty, or whitespace @NotBlank String name
@Size String/Collection/Map size constraints @Size(min = 3, max = 100) String name
@Min / @Max Numeric range constraints @Min(1) @Max(100) Integer age
@Email Email format validation @Email String email
@Pattern Regex pattern matching @Pattern(regexp = "^[A-Z]{2}[0-9]{6}$") String code

The @Valid annotation #

The @Valid annotation triggers cascading validation on nested objects:

public record CreateOrderRequest(
        @NotBlank String customerId,

        @Valid  // Triggers validation on nested object, meaning it will run validation on every LineItemRequest object
        List<LineItemRequest> items
) {
}

public record LineItemRequest(
        @NotBlank String productId,
        @Min(1) Integer quantity
) {
}

Method validation with @Validated #

While @Valid is for object graph validation, Spring’s @Validated annotation enables method-level validation:


@RestController
@RequestMapping("/orders")
@Validated  // Enables method validation
public class OrderController {

    @GetMapping("/{objectId}")
    public OrderResponse getByObjectId(
            @PathVariable @NotBlank @Size(min = 3, max = 40) String objectId
    ) {
        // objectId is validated before the method executes
        return orderService.getByObjectId(objectId);
    }
}

Key distinction:

In this case the @Validated annotation will enable the Jakarta bean validation. Without it, those annotations (@NotBlank, @Size) would be completely ignored.


Custom constraints: When the built-ins aren’t enough #

Sometimes the built-in constraints aren’t enough. This is where custom constraints shine.

Custom constraint example: A Swedish personal number validator #

In Sweden, we have something called a personal identity number. The example below covers how to write both a custom annotation you can use and a validator to actually validate the input.

First, create the custom annotation:


@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SwedishPersonalNumberValidator.class)
public @interface SwedishPersonalNumber {
    String message() default "Invalid Swedish personal number";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

Then create the validator:

// The implementation below is naive and ignores quite a few rules that would be required for a full-scale production-ready implementation.
public class SwedishPersonalNumberValidator implements ConstraintValidator<SwedishPersonalNumber, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            // Let @NotBlank handle required fields
            return true;
        }

        String normalized = value
                .replace("-", "")
                .replace("+", "");

        if (normalized.length() == 12) {
            normalized = normalized.substring(2);
        }

        if (!normalized.matches("\\d{10}")) {
            return false;
        }

        if (!isValidDate(normalized.substring(0, 6))) {
            return false;
        }

        return isValidChecksum(normalized.substring(0, 9), normalized.substring(9));
    }

    private static boolean isValidDate(String date) {
        try {
            LocalDate.parse(date, DateTimeFormatter.ofPattern("yyMMdd"));
            return true;
        } catch (DateTimeParseException _) {
            return false;
        }
    }

    private static boolean isValidChecksum(String pin, String checksum) {
        int sum = 0;

        for (int i = 0; i < pin.length(); i++) {
            int digit = Character.getNumericValue(pin.charAt(i));
            int temp = digit * (i % 2 == 0 ? 2 : 1);
            if (temp > 9) {
                temp -= 9;
            }
            sum += temp;
        }

        return (10 - (sum % 10)) % 10 == Integer.parseInt(checksum);
    }
}

Now you can use it in your DTO:

public record CreateCustomerRequest(
        @NotBlank String name,

        @NotBlank
        @SwedishPersonalNumber
        String personalNumber,

        @Email
        String email
) {
}

Custom constraint example: Configuration validation #

Sometimes you need to validate configuration properties. Here’s how you validate a message queue configuration:


@Validated
@QueueConfig
@ConfigurationProperties(prefix = "app.mq")
public record MqConfig(
        boolean enabled,
        String manager,
        String channel,
        @NotBlank String name,
        @NotBlank String host,
        @Min(1025) @Max(65535) Integer port,
        @NotBlank String user,
        @NotBlank String password,
        @Min(1) @Max(900) Integer batchSize,
        @NotBlank String encoding
) {
}

The custom annotation:


@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = QueueConfigValidator.class)
public @interface QueueConfig {
    String message() default "Queue configuration is invalid";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

And the validator:

public class QueueConfigValidator implements ConstraintValidator<QueueConfig, MqConfig> {

    @Override
    public boolean isValid(MqConfig config, ConstraintValidatorContext context) {
        if (!config.enabled()) {
            return true; // We do not need a valid configuration unless queues are enabled
        }

        boolean isValid = true;

        if (config.manager() == null || config.manager().isEmpty()) {
            isValid = false;
            context
                    .disableDefaultConstraintViolation()
                    .buildConstraintViolationWithTemplate("Manager is required when queue is enabled")
                    .addPropertyNode("manager")
                    .addConstraintViolation();
        }

        if (config.channel() == null || config.channel().isEmpty()) {
            isValid = false;
            context
                    .disableDefaultConstraintViolation()
                    .buildConstraintViolationWithTemplate("Channel is required when queue is enabled")
                    .addPropertyNode("channel")
                    .addConstraintViolation();
        }

        return isValid;
    }
}

If you run the application with a configuration that has app.mq.enabled set to true, all other required values set to something sensible, but leave the fields app.mq.name and app.mq.manager empty, the following output would be produced during startup:

***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target org.example.java.configurations.MqConfig failed:

    Reason: Queue configuration is invalid

    Property: app.mq.name
    Value: ""
    Reason: must not be blank

    Property: app.mq.manager
    Value: ""
    Reason: Manager is required when queue is enabled

Action:

Update your application's configuration

Domain layer validation: Business invariants #

While Bean Validation is great for checking data formats (boundary validation), the domain layer needs validation of business rules and invariants. This is where you ensure your core business objects stay valid.

In the domain layer, we move away from @NotNull and @Size. Here we resort to good old if statements — and this is finally where the weight problem from the first two posts in this series gets a permanent home. Recall the DbHandler defaulting null or zero weight to 1, and the mapper trap that tried to do the same thing in the wrong place. Here’s what that rule looks like once it lives where it belongs:

public record Weight(BigDecimal value) {

    private static final BigDecimal MINIMUM = BigDecimal.ONE;

    public Weight {
        if (value == null || value.signum() == 0) {
            value = MINIMUM; // business default: absent/zero weight means "not yet measured"
        } else if (value.signum() < 0) {
            throw new IllegalArgumentException("Weight cannot be negative");
        }
    }
}

public record Shipment(String id, Weight weight) {

    public Shipment {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("Shipment ID is required");
        }
        if (weight == null) {
            throw new IllegalArgumentException("Weight is required");
        }
    }
}

Why validate in the domain?


Persistence layer validation: Database constraints #

The persistence layer is the final gatekeeper. Even with robust application-level validation, you must protect your database integrity.

JPA/Hibernate annotations provide a direct mapping to database constraints:


@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @NotNull                                    // Bean Validation
    @Column(nullable = false, unique = true)    // DB Constraint
    private String email;

    @Column(nullable = false)                   // DB Constraint
    private LocalDate birthDate;

    // Getters...
}

Key Differences Between Layers

Layer Primary Concern Typical Tools Error Handling
Boundary Request/Response Format @Valid, @NotBlank HTTP 400 Bad Request
Domain Business Invariants Java Logic, Records Application Exceptions
Persistence Data Integrity JPA, DB Constraints Transaction Rollback

Why duplicate validation? Validation is redundant by design. If your application validation fails, the user gets a clear error immediately. If your database validation fails (e.g., concurrent updates bypassing application logic), the transaction rolls back safely.


Key takeaways #


What’s Next #

In the next article, we’ll cover the other half of the toolbox: testing strategies for each layer, structured API error responses, and how to organize validation code in a real project. Together, these close the loop between “how do I write a constraint” and “how do I know it actually works.”