Elegant State Validation in Spring Boot Applications

tags

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:

  1. Check if an action is possible before attempting it
  2. Show users all the reasons why an action might fail
  3. 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

  1. Separation of Concerns: Validation logic is separate from state transition logic
  2. Reusability: The same validation rules work for both checking and executing transitions
  3. Better User Experience: Frontend can show all validation errors at once
  4. Composability: Validators can be combined using the and() method
  5. Type Safety: Generic typing ensures compile-time type checking
  6. 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.