Data validation stands as the foundation of robust software development. It ensures that applications receive and process only valid information, maintaining data integrity and preventing errors. I’ve spent years implementing various validation techniques in Java applications, and I’d like to share the most effective strategies I’ve discovered.
Bean Validation with Jakarta Validation
Jakarta Bean Validation (formerly known as Java Bean Validation or JSR-380) provides a standardized way to define validation constraints through annotations. This declarative approach simplifies code and centralizes validation logic.
public class User {
@NotBlank(message = "Username is required")
@Size(min = 4, max = 50, message = "Username must be between 4 and 50 characters")
private String username;
@NotNull(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Password is required")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{8,}$",
message = "Password must contain at least 8 characters with numbers, lowercase and uppercase letters")
private String password;
// Getters and setters
}
To validate a User instance, you can use a Validator:
public class ValidationExample {
public static void main(String[] args) {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
User user = new User();
user.setUsername("jo"); // Too short
user.setEmail("not-an-email");
Set<ConstraintViolation<User>> violations = validator.validate(user);
violations.forEach(violation ->
System.out.println(violation.getPropertyPath() + ": " + violation.getMessage()));
}
}
In Spring applications, validation becomes even simpler:
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody User user, BindingResult result) {
if (result.hasErrors()) {
// Extract and return validation errors
List<String> errors = result.getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(Map.of("errors", errors));
}
// Process valid user
return ResponseEntity.ok(userService.createUser(user));
}
}
Programmatic Validation
While annotations are convenient, some validation rules require more complex logic. Programmatic validation gives you complete control over validation rules and error messages.
public class OrderValidator {
public ValidationResult validate(Order order) {
ValidationResult result = new ValidationResult();
if (order == null) {
result.addError("order", "Order cannot be null");
return result;
}
if (order.getItems() == null || order.getItems().isEmpty()) {
result.addError("items", "Order must contain at least one item");
}
if (order.getDeliveryAddress() == null) {
result.addError("deliveryAddress", "Delivery address is required");
}
// Calculate total from items to verify it matches the order total
BigDecimal calculatedTotal = order.getItems().stream()
.map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (calculatedTotal.compareTo(order.getTotalAmount()) != 0) {
result.addError("totalAmount", "Order total doesn't match sum of items");
}
return result;
}
}
public class ValidationResult {
private Map<String, List<String>> errors = new HashMap<>();
public void addError(String field, String message) {
errors.computeIfAbsent(field, k -> new ArrayList<>()).add(message);
}
public boolean isValid() {
return errors.isEmpty();
}
public Map<String, List<String>> getErrors() {
return errors;
}
}
I find programmatic validation particularly useful for complex business rules that can’t be easily expressed with annotations.
Cross-Field Validation
Sometimes validation needs to compare multiple fields. This is where cross-field validation becomes essential. Let’s implement this using both annotation-based and programmatic approaches.
Using annotations:
public class PasswordReset {
@NotBlank(message = "Password is required")
private String password;
@NotBlank(message = "Confirm password is required")
private String confirmPassword;
@AssertTrue(message = "Passwords don't match")
public boolean isPasswordsMatching() {
return password != null && password.equals(confirmPassword);
}
}
For more complex scenarios, a custom constraint annotation offers better reusability:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "Passwords don't match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String confirmPassword();
}
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {
private String passwordField;
private String confirmPasswordField;
@Override
public void initialize(PasswordMatch constraintAnnotation) {
this.passwordField = constraintAnnotation.password();
this.confirmPasswordField = constraintAnnotation.confirmPassword();
}
@Override
public boolean isValid(Object object, ConstraintValidatorContext context) {
try {
Object passwordValue = BeanUtils.getProperty(object, passwordField);
Object confirmPasswordValue = BeanUtils.getProperty(object, confirmPasswordField);
return passwordValue != null && passwordValue.equals(confirmPasswordValue);
} catch (Exception e) {
return false;
}
}
}
@PasswordMatch(password = "password", confirmPassword = "confirmPassword")
public class PasswordReset {
@NotBlank(message = "Password is required")
private String password;
@NotBlank(message = "Confirm password is required")
private String confirmPassword;
// Getters and setters
}
Validation Groups for Context-Specific Rules
Different operations often require different validation rules. For example, when creating a user, a password is mandatory, but when updating a user, it might be optional.
public interface CreateValidation {}
public interface UpdateValidation {}
public class UserDto {
@NotNull(groups = {CreateValidation.class, UpdateValidation.class})
@Min(value = 1, groups = UpdateValidation.class)
private Long id;
@NotBlank(groups = {CreateValidation.class, UpdateValidation.class})
private String name;
@NotBlank(groups = CreateValidation.class)
private String password;
@Email(groups = {CreateValidation.class, UpdateValidation.class})
private String email;
// Getters and setters
}
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<?> createUser(@Validated(CreateValidation.class) @RequestBody UserDto user) {
// Create user logic
return ResponseEntity.ok().build();
}
@PutMapping
public ResponseEntity<?> updateUser(@Validated(UpdateValidation.class) @RequestBody UserDto user) {
// Update user logic
return ResponseEntity.ok().build();
}
}
I’ve found validation groups indispensable in applications with complex workflows where the same domain object serves different purposes.
Custom Validation Constraints
Custom constraints allow you to encapsulate validation logic in reusable components. Here’s a practical example for validating a credit card number:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CreditCardValidator.class)
public @interface CreditCard {
String message() default "Invalid credit card number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class CreditCardValidator implements ConstraintValidator<CreditCard, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // Let @NotNull or @NotBlank handle this
}
// Strip any non-digit characters
String digits = value.replaceAll("\\D", "");
// Check for valid length
if (digits.length() < 13 || digits.length() > 19) {
return false;
}
// Luhn algorithm for credit card validation
int sum = 0;
boolean alternate = false;
for (int i = digits.length() - 1; i >= 0; i--) {
int digit = Character.digit(digits.charAt(i), 10);
if (alternate) {
digit *= 2;
if (digit > 9) {
digit = digit - 9;
}
}
sum += digit;
alternate = !alternate;
}
return sum % 10 == 0;
}
}
public class PaymentDetails {
@NotBlank(message = "Cardholder name is required")
private String cardholderName;
@NotBlank(message = "Credit card number is required")
@CreditCard
private String cardNumber;
@Pattern(regexp = "^(0[1-9]|1[0-2])/([0-9]{2})$",
message = "Expiry date must be in MM/YY format")
private String expiryDate;
@Pattern(regexp = "^[0-9]{3,4}$", message = "CVV must be 3 or 4 digits")
private String cvv;
// Getters and setters
}
Custom validators excel at encapsulating complex business rules that can be reused across your application.
Input Sanitization
Validation isn’t just about checking data—it’s also about protecting your application from potentially malicious input. Sanitization removes or escapes dangerous characters before they enter your system.
public class InputSanitizer {
// For plain text fields
public static String sanitizeText(String input) {
if (input == null) {
return null;
}
// Remove potentially dangerous characters
return input.trim()
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\"", """)
.replaceAll("'", "'")
.replaceAll("/", "/");
}
// For HTML content using OWASP HTML Sanitizer library
public static String sanitizeHtml(String html) {
if (html == null) {
return null;
}
PolicyFactory policy = new HtmlPolicyBuilder()
.allowElements("b", "i", "a", "p", "br", "ul", "ol", "li", "h1", "h2", "h3")
.allowUrlProtocols("https")
.allowAttributes("href").onElements("a")
.requireRelNofollowOnLinks()
.toFactory();
return policy.sanitize(html);
}
}
In a real-world service:
@Service
public class CommentService {
private final CommentRepository commentRepository;
public CommentService(CommentRepository commentRepository) {
this.commentRepository = commentRepository;
}
public Comment saveComment(Comment comment) {
// Sanitize user input before saving
comment.setTitle(InputSanitizer.sanitizeText(comment.getTitle()));
comment.setContent(InputSanitizer.sanitizeHtml(comment.getContent()));
return commentRepository.save(comment);
}
}
I can’t stress enough how important sanitization is. I once worked on a project where we discovered an XSS vulnerability because we skipped proper sanitization. The fix took weeks and damaged user trust.
Fail-Fast Validation
The fail-fast principle involves validating input as early as possible and immediately rejecting invalid data. This prevents corrupted data from propagating through your system.
public class ValidationUtils {
private static final Validator validator;
static {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
public static <T> void validate(T object) {
Set<ConstraintViolation<T>> violations = validator.validate(object);
if (!violations.isEmpty()) {
throw new ValidationException(buildErrorMessage(violations));
}
}
public static <T> void validate(T object, Class<?>... groups) {
Set<ConstraintViolation<T>> violations = validator.validate(object, groups);
if (!violations.isEmpty()) {
throw new ValidationException(buildErrorMessage(violations));
}
}
private static <T> String buildErrorMessage(Set<ConstraintViolation<T>> violations) {
return violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));
}
}
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(User user) {
// Validate immediately, before any business logic
ValidationUtils.validate(user, CreateValidation.class);
// Check if username is taken
if (userRepository.existsByUsername(user.getUsername())) {
throw new ValidationException("Username already exists");
}
// Continue with user creation
return userRepository.save(user);
}
}
I’ve implemented a layered validation approach in my applications:
- UI validation for immediate user feedback
- API validation to protect backend services
- Domain validation to ensure business rule compliance
- Database constraints as the final safety net
This defense-in-depth strategy catches errors at various levels, preventing data corruption.
Practical Application with Spring
Spring Boot makes implementing these validation strategies straightforward. Here’s a complete example bringing these concepts together:
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
public ResponseEntity<?> createProduct(@Valid @RequestBody ProductDto productDto,
BindingResult result) {
if (result.hasErrors()) {
Map<String, String> errors = new HashMap<>();
result.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
try {
Product product = productService.createProduct(productDto);
return ResponseEntity.ok(product);
} catch (ValidationException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product createProduct(ProductDto dto) {
// Convert DTO to entity
Product product = new Product();
product.setName(InputSanitizer.sanitizeText(dto.getName()));
product.setDescription(InputSanitizer.sanitizeHtml(dto.getDescription()));
product.setPrice(dto.getPrice());
product.setCategory(dto.getCategory());
// Programmatic validation for business rules
if (dto.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
throw new ValidationException("Product price must be positive");
}
if (productRepository.existsByName(dto.getName())) {
throw new ValidationException("Product with this name already exists");
}
return productRepository.save(product);
}
}
public class ProductDto {
@NotBlank(message = "Product name is required")
@Size(min = 3, max = 100, message = "Product name must be between 3 and 100 characters")
private String name;
@Size(max = 2000, message = "Description cannot exceed 2000 characters")
private String description;
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than zero")
private BigDecimal price;
@NotNull(message = "Category is required")
private ProductCategory category;
// Getters and setters
}
Conclusion
Effective validation is critical for building robust Java applications. By combining these strategies—Jakarta Bean Validation, programmatic validation, cross-field validation, validation groups, custom constraints, input sanitization, and fail-fast validation—you can create a comprehensive validation framework that protects your application at multiple levels.
My experience has shown that investing time in proper validation pays off enormously. It reduces bugs, improves security, enhances user experience, and ultimately saves development time by catching issues early.
Remember that validation isn’t just a technical concern—it’s about maintaining data integrity and ensuring your application behaves reliably. The most effective validation strategy combines technical checks with meaningful error messages that help users correct their input.
I encourage you to implement these validation strategies in your Java applications. Your users will thank you, even if they never realize how many problems your validation code has prevented.