One of the most frustrating mistakes developers make when designing validation logic is hardcoding validation rules inside business logic classes. Imagine sprinkling if (value == null || value.length() < 3)
checks across hundreds of DTOs—it quickly becomes unmanageable. The lack of separation between validation and domain logic makes the code brittle, hard to maintain, and nearly impossible to extend.
Annotation-based validation frameworks like javax.validation
(Bean Validation / Hibernate Validator) elegantly solved this problem by using annotations (@NotNull
, @Size
, @Pattern
) combined with reflection to enforce constraints outside of core business logic. Instead of writing repetitive checks, developers declare constraints at the field or method level, and the framework ensures they are enforced at runtime.
In this tutorial, we’ll walk through how to build a mini validation framework inspired by javax.validation
, powered by annotations + reflection.
Think of annotations here as rules written on sticky notes attached to data fields—and the validation engine is the inspector who reads those notes and checks if the data complies.
Step 1: Defining Validation Annotations
We start by creating annotations similar to javax.validation
.
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface NotNull {
String message() default "Field cannot be null";
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Size {
int min() default 0;
int max() default Integer.MAX_VALUE;
String message() default "Field size is out of bounds";
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Pattern {
String regex();
String message() default "Field does not match pattern";
}
Key points:
@Retention(RUNTIME)
ensures the framework can read the annotations at runtime via reflection.@Target(FIELD)
ensures annotations apply to fields only.
Step 2: Writing the Validation Engine
The core engine scans fields via reflection and applies the rules defined by annotations.
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.PatternSyntaxException;
public class Validator {
public static List<String> validate(Object obj) {
List<String> errors = new ArrayList<>();
Class<?> clazz = obj.getClass();
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
try {
Object value = field.get(obj);
// Handle @NotNull
if (field.isAnnotationPresent(NotNull.class) && value == null) {
NotNull notNull = field.getAnnotation(NotNull.class);
errors.add(notNull.message());
}
// Handle @Size
if (field.isAnnotationPresent(Size.class) && value instanceof String) {
Size size = field.getAnnotation(Size.class);
String str = (String) value;
if (str.length() < size.min() || str.length() > size.max()) {
errors.add(size.message());
}
}
// Handle @Pattern
if (field.isAnnotationPresent(Pattern.class) && value instanceof String) {
Pattern pattern = field.getAnnotation(Pattern.class);
String str = (String) value;
if (!str.matches(pattern.regex())) {
errors.add(pattern.message());
}
}
} catch (IllegalAccessException | PatternSyntaxException e) {
errors.add("Validation error: " + e.getMessage());
}
}
return errors;
}
}
Step 3: Using the Framework
public class User {
@NotNull(message = "Username cannot be null")
private String username;
@Size(min = 5, max = 10, message = "Password must be between 5 and 10 characters")
private String password;
@Pattern(regex = "^\S+@\S+\.\S+$", message = "Invalid email format")
private String email;
public User(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
}
public class Main {
public static void main(String[] args) {
User user = new User(null, "12", "invalidEmail");
List<String> errors = Validator.validate(user);
if (!errors.isEmpty()) {
errors.forEach(System.out::println);
} else {
System.out.println("Validation passed!");
}
}
}
Output:
Username cannot be null
Password must be between 5 and 10 characters
Invalid email format
Step 4: Common Pitfalls
- Performance Overhead – Reflection is slower; cache field/annotation metadata for large applications.
- Module Restrictions – From Java 9 onwards, accessing private fields may require
--add-opens
. - Annotation Retention – Forgetting
@Retention(RUNTIME)
makes annotations invisible at runtime. - Generic Error Messages – Provide meaningful defaults but allow customization.
- Extensibility – Without a clean API, adding new constraints becomes hard.
📌 What's New in Java Versions?
- Java 5: Introduced annotations, enabling the foundation of validation frameworks.
- Java 8: Repeatable annotations (useful for multiple constraints like
@Size
+@Pattern
). - Java 9: Stronger module boundaries—reflection-based validators may need
--add-opens
. - Java 11: Continued enforcement of encapsulation rules.
- Java 17: Sealed classes help framework authors restrict validator implementations.
- Java 21: No significant updates across Java versions for this feature.
Real-World Analogy
Think of a customs checkpoint at an airport. Each traveler (object) carries a passport (field). Annotations are rules stamped on the passport: “Must not be null,” “Length should be 5–10,” or “Must match this regex.” The validation officer (our framework) inspects each rule via reflection and either approves entry or flags violations.
Best Practices
- Always define
RetentionPolicy.RUNTIME
. - Use
Target
wisely (fields vs methods). - Provide clear error messages.
- Cache metadata for high performance.
- Keep validators modular and pluggable.
- Integrate with frameworks like Spring for declarative validation.
Summary + Key Takeaways
- Annotation-based validation frameworks separate validation from domain logic.
- Reflection is the backbone that makes annotations actionable at runtime.
- Building custom annotations is straightforward but requires careful handling of performance and module system limitations.
- This approach scales from small DTO checks to enterprise-level validation engines.
FAQs
Q1. Why use annotations for validation instead of utility classes?
Annotations keep validation rules close to data, reducing boilerplate and centralizing rule enforcement.
Q2. Can I validate methods as well as fields?
Yes, by changing @Target
to METHOD
or both FIELD
and METHOD
.
Q3. How can I optimize reflection performance in validation?
Cache field and annotation metadata instead of scanning classes repeatedly.
Q4. What happens if I forget @Retention(RUNTIME)
?
The annotation won’t be visible to the framework, and validation won’t occur.
Q5. How do I support custom validators (like @Min
or @Max
)?
Define new annotations and extend the validator to interpret them.
Q6. How does Hibernate Validator extend javax.validation?
It builds on the Bean Validation spec, adding advanced validators like @Email
, cross-field validation, and i18n support.
Q7. How does Java 9+ affect reflection in validators?
Encapsulation may block reflective access; you might need JVM flags like --add-opens
.
Q8. Can I integrate annotation validation with frameworks like Spring?
Yes, Spring Boot auto-integrates Hibernate Validator; custom ones can hook into AOP or interceptors.
Q9. Is it possible to create conditional validators (validate only if another field is set)?
Yes, but it requires custom logic combining reflection across multiple fields.
Q10. What are the trade-offs of annotation validation vs. programmatic validation?
Annotations provide readability and declarative style, but are less dynamic compared to full programmatic checks.
Q11. Can annotations be used for cross-cutting validations like logging or auditing?
Yes, annotations + reflection (or AOP) can enforce cross-cutting concerns beyond validation.