Baeldung

Java, Spring and Web Development tutorials

 

Build Stateful Custom Bean Validation with Spring Boot
2025-09-12 18:01 UTC by Ashley Frieze

1. Overview

The Java standard for Bean Validation is available out of the box in Spring Boot via the Hibernate Validator reference implementation. It allows us to add standard annotations such as @NotNull to fields of request object classes to enable Spring to validate its inputs.

We can also extend the available validations. Additionally, we may need to use runtime data to implement our logic. For example, a value may only be valid if it can be found in our database or runtime configuration, making our validation algorithm stateful.

We may also require cross-field validation, where validation considers multiple values in an object to determine if related fields are valid.

In this tutorial, we’ll explore building custom validations.

2. Bean Validation Fundamentals

Validating a Java bean uses the JSR-380 framework. This provides general-purpose validation annotations in the jakarta.validation package, along with a Validator interface.

2.1. Annotations

To validate a field, we add an annotation to its declaration:

@Pattern(regexp = "A-\\d{8}-\\d")
private String productId;

During validation by the chosen instance of Validator, the annotation and its metadata (in this case, the pattern. regex) are used to determine the validation rules. The validator achieves this by finding a suitable validation implementation for each annotation when calling validate().

In this example, we’ve put a validation on a field, but we can also create type-level validations, as we’ll see later.

2.2. Validating Requests

In a Spring @RestController, we can annotate our request body with @Valid to ask for it to be validated automatically:

@PostMapping("/api/purchasing/")
public ResponseEntity<String> createPurchaseOrder(@Valid @RequestBody PurchaseOrderItem item) {
    // ... execute the order
    return ResponseEntity.accepted().build();
}

The body of our controller will only be called if the request is valid.

We can test this using a MockMvc test:

mockMvc.perform(post("/api/purchasing/")
    .content("{}")
    .contentType(MediaType.APPLICATION_JSON))
  .andExpect(status().isBadRequest());

Here, an empty JSON is not valid and results in an HTTP 400, BAD REQUEST.

3. Example Use Case

Next, let’s build a SaaS product that can process purchase orders via an API that receives PurchaseOrderItems:

public class PurchaseOrderItem {
    @NotNull
    @Pattern(regexp = "A-\\d{8}-\\d")
    private String productId;
    private String sourceWarehouse;
    private String destinationCountry;
    private String tenantChannel;
    private int numberOfIndividuals;
    private int numberOfPacks;
    private int itemsPerPack;
    @org.hibernate.validator.constraints.UUID
    private String clientUuid;
    // getters and setters
}

We’ve already added some built-in validations to this object. Initially, we require the productId to be non-null and match a specific pattern. We also expect clientUuid to be a valid UUID. We’ve used both jakarta and hibernate validations. But our requirements need additional rules, which require custom code and validators.

First, we want to make sure the productId matches a custom check digit algorithm.

Next, it’s not valid for an order to include packs and individual items, so only numberOfIndividuals or numberOfPacks can be set.

Finally, the chosen warehouse must be able to ship to the destination country, and the tenantChannel must be configured on our server.

We’ll need a mixture of algorithmic and data-driven custom annotations to implement these validations. Our rules require some validation to involve multiple fields. Additionally, we’ll depend on data from both Spring properties and our database for validations, making these stateful validators.

4. Single Field Custom Validator

We can build a custom validator by providing both the annotation and the implementation of the algorithm. Let’s make a check digit validator for our product ID.

4.1. Define a Field Validation Annotation

The validation annotation needs some standard properties:

@Constraint(validatedBy = ProductCheckDigitValidator.class)
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface ProductCheckDigit {
    String message() default "must have valid check digit";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

The annotation must have a Retention of RUNTIME to be available to the validator, and we can decide whether the annotation targets fields or a whole type. In this case, we have a field-level annotation, indicated by the element type array containing FIELD in the @Target annotation. Depending on the nature of the validation, we can even include function parameter validations.

The @Constraint annotation declares which class (or classes) handles this validation. This lets the validator run our custom validation code.

Finally, we should note that the default error message is in the message property here.

4.2. Creating the Custom Validator Class

Next, we need to create our validator class and override the isValid() method:

public class ProductCheckDigitValidator implements ConstraintValidator<ProductCheckDigit, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
       // implementation here
    }
}

Our check digit logic only needs to return false for the validator to be able to mark our field as invalid.

Specifically, this validator must implement the ConstraintValidator interface. We also declare the types this validator applies to. The first type declaration is our annotation, and the second type is the type to validate. In this case, our validator works on Strings annotated with ProductCheckDigit. To use our validation annotation on multiple types of fields, we’d write a custom validator class for each specific type, and have an array of validators in the validatedBy value of our @Constraint annotation.

4.3. Preparing Some Test Cases

Let’s set up our unit test and our request class before implementing the check digit logic.

First, we add the new annotation to our entity class:

public class PurchaseOrderItem {
    @ProductCheckDigit
    @NotNull
    @Pattern(regexp = "A-\\d{8}-\\d")
    private String productId;
    // ...
}

And then we create a unit test that has access to the validator:

@SpringBootTest
class PurchaseOrderItemValidationUnitTest {
    @Autowired
    private Validator validator;
    @Test
    void givenValidProductId_thenProductIdIsValid() {
        PurchaseOrderItem item = createValidPurchaseOrderItem();
        item.setProductId("A-12345678-6");
        Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
        assertThat(violations).isEmpty();
    }
}

Here we have a test factory method to create a PurchaseOrderItem that’s completely valid, so we’re able to focus on the effect of individual fields in each of our tests. We’re also calling the validator directly to see what violations we find.

We should note that the Validator object can be provided by Spring to any of our components, so we’re not restricted to just using validation where Spring applies it automatically.

4.4. Implement the Check Digit

Our product identifier has two numeric sections – an eight-digit number and a check digit, which is the last digit of the sum of the first eight digits. Let’s extract these parts and test the check digit:

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
    if (value == null) {
        return false;
    }
    String[] parts = value.split("-");
    return parts.length == 3 && checkDigitMatches(parts[1], parts[2]);
}
private static boolean checkDigitMatches(String productCode, String checkDigit) {
    int sumOfDigits = IntStream.range(0, productCode.length())
            .map(character -> Character.getNumericValue(productCode.charAt(character)))
            .sum();
    int checkDigitProvided = Character.getNumericValue(checkDigit.charAt(0));
    return checkDigitProvided == sumOfDigits % 10;
}

We validate the check digit by splitting the product ID and then summing the individual numbers of the middle numeric string.

4.5. When the Check Fails

Calling the validator provides a set of constraint violations. To make that easier to test with, let’s turn it into a list of field paths and error messages:

private static List<String> collectViolations(Set<ConstraintViolation<PurchaseOrderItem>> violations) {
    return violations.stream()
        .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
        .sorted()
        .collect(Collectors.toList());
}

Now we can check the error we get when a check digit doesn’t match:

PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setProductId("A-12345678-1");
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(collectViolations(violations))
  .containsExactly("productId: must have valid check digit");

Additionally, if we make the field null, we get multiple errors because our custom validator and the NotNull validator fail:

PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setProductId(null);
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(collectViolations(violations))
  .containsExactly(
    "productId: must have valid check digit",
    "productId: must not be null");

5. Multi-Field Validator

Now we’ve built a single field validator, let’s look at the rule where we must choose either individuals or packs of items.

5.1. Create the Validation Annotation

For multi-field validation, we need to apply the validation to the parent type:

@Constraint(validatedBy = ChoosePacksOrIndividualsValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface ChoosePacksOrIndividuals {
    String message() default "";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

This annotation has a target of TYPE, as it’s intended for use with the PurchaseOrderItem type.

We need to validate the whole of our PurchaseOrderItem here, since a per-field validation would only look at a specific field with none of the surrounding context. Cross-field validation is achieved at the type level.

5.2. Create the Validator

The validator needs to create different constraint violations when both or neither of the quantities are set. It needs to avoid the default errors that the validation framework might create when isValid() returns false.

We start by creating the class, which binds the ability to validate a PurchaseOrderItem with the ChoosePacksOrIndividual annotation:

public class ChoosePacksOrIndividualsValidator 
  implements ConstraintValidator<ChoosePacksOrIndividuals, PurchaseOrderItem> {}

In the isValid() method, we start by disabling the default error messaging:

@Override
public boolean isValid(PurchaseOrderItem value, ConstraintValidatorContext context) {
    context.disableDefaultConstraintViolation();
    ...

This allows us to customize the error message instead of using the default message from the annotation.

Next, we can implement our logic for determining if the fields are valid, adding constraint violations to the fields that prove to be invalid:

boolean isValid = true;
if ((value.getNumberOfPacks() == 0) == (value.getNumberOfIndividuals() == 0)) {
    // either both are zero, or both are turned on
    isValid = false;
    context.disableDefaultConstraintViolation();
    if (value.getNumberOfPacks() == 0) {
        context.buildConstraintViolationWithTemplate("must choose a quantity when no packs")
                .addPropertyNode("numberOfIndividuals")
                .addConstraintViolation();
        context.buildConstraintViolationWithTemplate("must choose a quantity when no individuals")
                .addPropertyNode("numberOfPacks")
                .addConstraintViolation();
    } else {
        context.buildConstraintViolationWithTemplate("cannot be combined with number of packs")
                .addPropertyNode("numberOfIndividuals")
                .addConstraintViolation();
        context.buildConstraintViolationWithTemplate("cannot be combined with number of individuals")
                .addPropertyNode("numberOfPacks")
                .addConstraintViolation();
    }
}
if (value.getNumberOfPacks() > 0 && value.getItemsPerPack() == 0) {
    isValid = false;
    context.buildConstraintViolationWithTemplate("cannot be 0 when using packs")
            .addPropertyNode("itemsPerPack")
            .addConstraintViolation();
}
return isValid;

This algorithm checks whether we’ve got both fields at zero, or both fields at non-zero, which indicates that we’ve either specified both of them, or neither. It then adds a custom constraint violation for both fields to explain which mistake has been made.

5.3. Testing the Cross-Field Validation

First, we need to add the new annotation to our PurchaseOrderItem:

@ChoosePacksOrIndividuals
public class PurchaseOrderItem {
}

Then we can test an invalid combination:

PurchaseOrderItem item = createValidPurchaseOrderItem();
item.setNumberOfIndividuals(10);
item.setNumberOfPacks(20);
item.setItemsPerPack(0);
Set<ConstraintViolation<PurchaseOrderItem>> violations = validator.validate(item);
assertThat(collectViolations(violations))
  .containsExactly(
    "itemsPerPack: cannot be 0 when using packs",
    "numberOfIndividuals: cannot be combined with number of packs",
    "numberOfPacks: cannot be combined with number of individuals");

6. Stateful Validation Using Spring Properties

So far, we’ve bound static code to annotations and used algorithms with information known at compile time. We may need to use configuration properties to determine what’s valid.

6.1. Configuration of Valid Channels

Let’s say we had some runtime configuration properties:

com.baeldung.tenant.channels[0]=retail
com.baeldung.tenant.channels[1]=wholesale

This would be some data in our application.properties, which we load into a ConfigurationProperties class:

@ConfigurationProperties("com.baeldung.tenant")
public class TenantChannels {
    private String[] channels;
    // getter/setter
}

Now we want to be able to use this array of channels in a validator to check that the chosen channel in the request is available in this tenant.

6.2. Creating a Validator with a Bean Injected

Since Spring provides the validator, we can also inject other Spring beans into our validator. So we can autowire configuration properties into a custom validator:

public class AvailableChannelValidator implements ConstraintValidator<AvailableChannel, String> {
    @Autowired
    private TenantChannels tenantChannels;
}

Using the array from the properties object to check each channel at validation time is a little clunky. Let’s override the initialize() method to turn that value into a set:

private Set<String> channels;
@Override
public void initialize(AvailableChannel constraintAnnotation) {
    channels = Arrays.stream(tenantChannels.getChannels()).collect(toSet());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
    return channels.contains(value);
}

Now we have a validation annotation driven by the properties file of our server’s current Spring profile. We just need to annotate the field in our PurchaseOrderItem:

@AvailableChannel
private String tenantChannel;

7. Validation Based on Data

Once we can validate our fields with a Spring bean, we can use the same technique to leverage our database or other web services:

@Repository
public class WarehouseRouteRepository {
    public boolean isWarehouseRouteAvailable(String sourceWarehouse, String destinationCountry) {
        // read from database
    }
}

This repository can be injected into a validator:

public class AvailableWarehouseRouteValidator implements 
  ConstraintValidator<AvailableWarehouseRoute, PurchaseOrderItem> {
    @Autowired
    private WarehouseRouteRepository warehouseRouteRepository;
    @Override
    public boolean isValid(PurchaseOrderItem value, ConstraintValidatorContext context) {
        return warehouseRouteRepository.isWarehouseRouteAvailable(value.getSourceWarehouse(), 
          value.getDestinationCountry());
    }
}

Finally, since this validates multiple fields of the purchase order, we’ll add the related annotation at the class level:

@ChoosePacksOrIndividuals
@AvailableWarehouseRoute
public class PurchaseOrderItem {
    ...
}

8. Conclusion

In this article, we looked at how to add validations to fields and types. We wrote custom validation logic linked to custom annotations, and we saw how to operate on individual fields with default error messages or multiple fields with customized error messages.

Finally, we wrote stateful validators leveraging other Spring beans to make a validation based on runtime configuration or data.

As always, the full example code for this article is available over on GitHub.

The post Build Stateful Custom Bean Validation with Spring Boot first appeared on Baeldung.
       

 

Content mobilized by FeedBlitz RSS Services, the premium FeedBurner alternative.