Many Spring Boot applications deal with entities that can exist in different states - like a parcel that moves from “waiting for posting” to “shipped” to “picked up.” While implementing state transitions is straightforward, validating whether these transitions are allowed can become complex. This article explores an elegant solution to handle state validation in a way that’s both reusable and user-friendly.
The Challenge: State Transitions in Practice
Let’s consider a real-world example: a parcel delivery system. Each parcel in our system goes through several states:
- Waiting for posting
- Shippped
- Picked up
Initially, we might implement state transitions using a simple service class called ParcelStateMachine. Here’s how a basic implementation looks:
public class ParcelStateMachine {
public ParcelDetail shipped(String id) {
return update(id, this::shipped);
}
private ParcelDetail shipped(ParcelDetail parcel) {
validator.checkTransitionTo(parcel, SHIPPED);
validator.checkHasBeenPosted(parcel);
validator.checkDeliveryAddressExists(parcel);
parcel.setState(SHIPPED);
parcel.setShippedDate(LocalDateTime.now());
return parcel;
}
}This implementation works, but it has a limitation: we can only know if a transition is valid by attempting it and catching exceptions. In our validator, we’re throwing exceptions when validations fail:
public class ParcelValidator {
public void checkTransitionTo(ParcelDetail parcel, ParcelState toState) {
Set<ParcelState> allowedTransitions = parcel.getState().getTransitions();
if (!allowedTransitions.contains(toState)) {
throw new IllegalStateException("Could not transition to state " + toState);
}
}
}The User Experience Problem
This approach creates a challenge for our user interface. We want to disable or hide the “Ship” button when shipping isn’t possible, but we don’t want to duplicate our validation logic in the frontend. We need a way to check if a state transition is possible before attempting it.
A Better Solution: Composable Validators
Instead of throwing exceptions immediately, we can create a validation system that collects all potential issues. This approach allows us to:
- Check if an action is possible before attempting it
- Show users all the reasons why an action might fail
- Reuse the same validation logic for both checking and executing
Here’s our new validator interface:
@FunctionalInterface
public class Validator<T> {
ValidationResult validate(T t);
// Combine multiple validators
default Validator<T> and(Validator<T> other) {
return (entity) -> {
ValidationResult firstResult = this.validate(entity);
ValidationResult secondResult = other.validate(entity);
if (firstResult.isValid() && secondResult.isValid()) {
return ValidationResult.valid();
}
// Collect all validation errors
return new ValidationResult(false,
union(firstResult.getErrors(), secondResult.getErrors()));
};
}
// Convenience methods
default boolean isValid(T entity) {
return this.validate(entity).isValid();
}
// Transform input type
default <U> Validator<U> map(Function<U, T> mapper) {
return (entity) -> this.validate(mapper.apply(entity));
}
// Create validator from predicate
static <T> Validator<T> of(Predicate<T> predicate, String errorCode, Object data) {
return (entity) -> predicate.test(entity)
? ValidationResult.valid()
: ValidationResult.invalid(errorCode, data);
}
}The ValidationResult class holds either success or a list of validation errors:
@Value
public class ValidationResult {
boolean valid;
@With List<ValidationError> errors;
public static ValidationResult valid() {
return new ValidationResult(true, Collections.emptyList());
}
public static ValidationResult invalid(String exceptionCode, Object data) {
return new ValidationResult(false,
List.of(new ValidationError(exceptionCode, data)));
}
@Value
public static class ValidationError {
String code;
Object data;
}
}Now we can rewrite our state transition validator to return validation results instead of throwing exceptions:
public class ParcelValidator {
public Validator<ParcelDetail> checkTransitionTo(ParcelState toState) {
return (parcel) -> {
Set<ParcelState> allowedTransitions =
parcel.getState().getTransitions();
return Validator.of(
allowedTransitions.contains(toState),
INVALID_TRANSITION,
null
);
}
}
}Putting It All Together
With our new validation system, we can create cleaner, more informative state transitions:
public class ParcelStateMachine {
public ValidationResult canBeShipped(String id) {
ParcelDetail parcel = find(id);
return canBeShipped().validate(parcel);
}
public ParcelDetail shipped(String id) {
return update(id, this::shipped);
}
private Validator<ParcelDetail> canBeShipped() {
return validator.canTransitionTo(SHIPPED)
.and(validator.hasBeenPosted())
.and(validator.deliveryAddressExists());
}
private ParcelDetail shipped(ParcelDetail parcel) {
ValidationResult result = canBeShipped().validate(parcel);
if (!result.isValid()) {
throw new ValidationException("Cannot ship parcel: ", result);
}
parcel.setState(SHIPPED);
parcel.setShippedDate(LocalDateTime.now());
return parcel;
}
}The API Response
Our API can now return detailed validation results:
{
"valid": false,
"errors": [
{
"code": "INVALID_TRANSITION",
"data": null
},
{
"code": "DELIVERY_ADDRESS_DOES_NOT_EXIST",
"data": "Address On Mars 12/E"
}
]
}Benefits of This Approach
- Separation of Concerns: Validation logic is separate from state transition logic
- Reusability: The same validation rules work for both checking and executing transitions
- Better User Experience: Frontend can show all validation errors at once
- Composability: Validators can be combined using the
and()method - Type Safety: Generic typing ensures compile-time type checking
- Extensibility: Easy to add new validation rules without modifying existing code
This pattern helps create a more maintainable and user-friendly application while keeping our business logic clean and organized.