With Spring, data validation is a breeze in many common use cases (like validating a method's input parameters) - and is highly recommended for creating robust applications.
Often, developers are called upon to produce software with more complex validation requirements. This guide describes one such situation and proposes a solution that is effective and simple to maintain.
Visit this GitHub repo for a working example of the article's concepts. For a full technical guide for creating cross-parameter validations, refer to this JBoss guide.
Business Case
Suppose you are developing an e-commerce application where users can request for a bouquet of flowers to be delivered to an address within a specific window of time. An order POJO might take the shape of:
package io.focusedlabs.crossparametervalidation;
import java.time.ZonedDateTime;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class FlowerDeliveryOrder {
private String orderId;
private String recipientName;
private ZonedDateTime orderPlaced;
private ZonedDateTime deliveryStart;
private ZonedDateTime deliveryEnd;
}
Here we are using Lombok's
@Data
and@Builder
annotations to auto-generate boilerplate code, saving keystrokes and valuable developer time.
Simple Validations
The Bean Validation API can be used to enforce some simple and common validations, such as guaranteeing a FlowerDeliveryOrder
will have a non-null orderId
:
package io.focusedlabs.crossparametervalidation;
import java.time.ZonedDateTime;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotEmpty;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class FlowerDeliveryOrder {
@NotNull
private String orderId;
@NotEmpty(message = "Recipient Name must be specified")
private String recipientName;
private ZonedDateTime orderPlaced;
private ZonedDateTime deliveryStart;
private ZonedDateTime deliveryEnd;
}
With this simple validation in place, flower delivery orders with an empty
recipientName
will result in a thrown ConstraintViolationException that can be handled gracefully by the application.
Cross-Parameter Validations
The simple validations above are useful for our application, but what if we want to validate business logic that depends on multiple fields? For example, if the user of the web form selects a deliveryStart
that is after the deliveryEnd
, the order should not be accepted.
It might be tempting to implement an isValid()
method inside the FlowerDeliveryOrder
and manually check the result wherever the orders are processed. This may be appropriate sometimes, but the developers must remember to include the check everywhere the order is processed.
Instead, we can introduce a custom validation that inspects both parameters and integrates seamlessly with Spring's bean validation mechanism.
Operating Principle
There are two parts to this solution: 1) a custom annotation to mark the class for validation and 2) a custom validator that encapsulates the business logic to be executed.
Custom Annotation
An annotation is a form of interface and are declared in a similar fashion. Below, we create a custom annotation that can adorn our FlowerDeliveryOrder
class:
package io.focusedlabs.crossparametervalidation;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderDeliveryWindowValidator.class)
public @interface ValidDeliveryWindow {
String message() default "Delivery Window Start Time must precede End Time";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
In this configuration, the annotation can be placed on classes with
@Target({ElementType.TYPE})
and is available to the application at runtime with@Retention(RuntimePolicy.RUNTIME)
.
The Bean Validation API requires message
and groups
at a minimum; we can add a payload
field to attach custom data to the constraint.
Custom Validator
The @Constraint
annotation specifies a class that should be used by the Bean Validation API to perform the custom logic. This class must implement the ConstraintValidator
interface.
package io.focusedlabs.crossparametervalidation;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class OrderDeliveryWindowValidator implements ConstraintValidator<ValidDeliveryWindow, FlowerDeliveryOrder> {
public void initialize(ValidDeliveryWindow constraintAnnotation) {
}
public boolean isValid(FlowerDeliveryOrder order, ConstraintValidatorContext constraintContext) {
return order.deliveryStart.isBefore(order.deliveryEnd);
}
}
Notice that the
isValid()
method is very similar to how a developer might write an inline validation. This is great news, because it means that existing validations be easily refactored to use this pattern.
Finishing Up
With the building blocks in place, we can now apply this custom validation to our FlowerDeliveryOrder
:
package io.focusedlabs.crossparametervalidation;
import java.time.ZonedDateTime;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.NotEmpty;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
@ValidDeliveryWindow
public class FlowerDeliveryOrder {
@NotNull
private String orderId;
@NotEmpty(message = "Recipient Name must be specified")
private String recipientName;
private ZonedDateTime orderPlaced;
private ZonedDateTime deliveryStart;
private ZonedDateTime deliveryEnd;
}
Parting Thoughts
While there is some initial time investment required to create a custom annotation and validator, change management becomes much easier from that point forward - and the code can be understood by developers at most skill levels. It is a trusted tool at Focused Labs.
Cover photo is my own: Flickr
Top comments (2)
Great technique that opens the door for validation reuse! Thanks for posting Ryan.
How would you test a cross parameter validation?