Java Annotations: A Practical Introduction Link to heading
Understanding the @ Symbol in Your Code Link to heading
As you start writing more and more Java code, you will inevitably encounter the slightly mysterious @
symbols
preceding various code constructs. These symbols mark annotations - one of Java’s most powerful yet initially
confusing features.
Consider these common examples that appear in everyday Java code:
class Animal {
public void speak() {
System.out.println("Animal speaks");
}
}
class Dog extends Animal {
@Override
public void speak() {
System.out.println("Dog barks");
}
@Deprecated
public void oldMethod() {
System.out.println("Don't use this anymore");
}
@SuppressWarnings("unused")
private void unusedMethod() {
// Suppressed warning about this unused method
}
}
Or perhaps you’ve worked with testing frameworks and seen code like this:
import org.junit.jupiter.api.*;
class MyServiceTest {
@BeforeEach
void setUp() {
System.out.println("Setting up before each test");
}
@AfterEach
void tearDown() {
System.out.println("Cleaning up after each test");
}
@Test
void testOne() {
System.out.println("Running test one");
}
@Test
@Disabled("Feature under development")
void testTwo() {
System.out.println("This test will be skipped");
}
}
When first encountering these annotations, they can seem like mysterious incantations - code elements that don’t follow the typical object-oriented or procedural patterns you’ve learned. This makes building an intuitive mental model of annotations challenging for newcomers. It’s initially not clear what they’re actually doing, and more importantly, how they fold into the implementation model and at what point they “execute” and take effect. While we’ll leave the “what they are actually doing” to the documentation from Java language and library authors, this 2 part article will focus on how annotations function - demystifying their behavior and demonstrating their practical application through concrete examples.
What Are Annotations, Really? Link to heading
At their core, annotations are a form of metadata that you can add to your Java code. Unlike comments which are ignored by the compiler, annotations are structured metadata that can be processed by:
- The compiler (like
@Override
and@Deprecated
) - Development tools and IDEs (for code generation or validation)
- Runtime libraries and frameworks (like JUnit’s
@Test
)
Think of annotations as “tags” that mark your code with instructions for special processing. They represent a shift toward declarative programming - where you express what should happen rather than explicitly coding how it should happen.
Key Characteristics of Annotations Link to heading
- Non-executable: Annotations don’t “do” anything by themselves
- Metadata carriers: They store information about the code elements they annotate
- Processable: They can be read and acted upon by various tools or by your code via reflection
- Applicable to many elements: They can annotate classes, methods, fields, parameters, and even other annotations
Where Can Annotations Be Applied? Link to heading
Java annotations can be applied to virtually any declaration:
- Class declarations
- Method declarations
- Field declarations
- Parameter declarations
- Local variables
- Package declarations
- Type parameters
- Type uses
- Module declarations
Why Annotations Matter Link to heading
Annotations fundamentally changed how Java frameworks are designed. Before annotations, frameworks often relied on:
- XML configuration files
- Naming conventions
- Inheritance hierarchies
These approaches often led to verbose configurations, tight coupling, and code that was hard to maintain. Annotations helped solve these problems by:
- Reducing boilerplate code: Place an annotation directly where it’s relevant
- Improving code readability: Express intention clearly at the point of declaration
- Supporting compile-time checking: Many annotation errors can be caught early
- Enabling powerful tooling: Code generation, validation, and documentation
Types of Java Annotations Link to heading
Java annotations generally fall into three categories:
Marker annotations: Simple annotations that mark a declaration for special handling
@Override public void toString() { ... }
Single-value annotations: Carry a single piece of data
@SuppressWarnings("unchecked") public List getItems() { ... }
Full annotations: Carry multiple pieces of data
@RequestMapping(path = "/users", method = RequestMethod.GET) public List<User> getUsers() { ... }
Understanding By Building Link to heading
As with many programming concepts, the best way to understand annotations is to build something practical with them. In this article, we’ll create a custom field validation system using annotations.
This approach will demonstrate how annotations can:
- Separate validation logic from business logic
- Make code more declarative and self-documenting
- Leverage Java’s reflection capabilities for runtime processing
This is Part 1 of a two-part series. In the next installment I’ll show the use of method annotations, like the @Test
Junit annotation.
So, let’s dive in and create our own custom field validator annotations to see Java’s annotation system in action.
Java Annotations: Creating Custom Field Validators Link to heading
Modern Java applications require robust data validation to ensure the integrity and correctness of data flowing through your systems. While there are excellent validation libraries like Hibernate Validator (the reference implementation of Jakarta Bean Validation), understanding how to create your own custom annotations gives you greater flexibility and deeper insight into Java’s powerful meta-programming capabilities.
In this post, we’ll explore how to create custom field validation annotations in Java 21, demonstrating the power of Java’s annotation system through practical, real-world examples.
Why Custom Validation Annotations? Link to heading
There are several benefits to using custom validation annotations:
- Declarative validation: Define what’s valid directly where the field is declared
- Separation of concerns: Keep validation logic separate from business logic
- Reusability: Define validations once and use them throughout your codebase
- Self-documenting code: The validation requirements are visible in the code
- Extensibility: Create domain-specific validations tailored to your needs
Creating Custom Validation Annotations Link to heading
Let’s create a set of custom validation annotations for common field types: email addresses, IP addresses, strings with length constraints, and zip codes with exact length requirements.
1. Defining the Annotations Link to heading
First, we’ll define our annotations:
package com.example.validation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
// Annotation to validate email address
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Email {
String message() default "Invalid email format";
}
// Annotation to validate IPv4 address
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface IpAddress {
String message() default "Invalid IP address format";
}
// Generic string length validation annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface StringLength {
int max() default Integer.MAX_VALUE;
int min() default 0;
String message() default "String length out of allowed range";
}
// Generic exact string length validation annotation. This could be useful for validating things like Zip codes
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface ExactLength {
int length();
String message() default "Field must be exactly {length} characters";
}
Let’s break down what’s happening here:
@Retention(RetentionPolicy.RUNTIME)
indicates that these annotations should be available at runtime through reflection.@Target(ElementType.FIELD)
specifies that these annotations can only be applied to fields.- Each annotation has a
message()
method that provides a customizable error message. StringLength
hasmin()
andmax()
methods to define the allowed range.ExactLength
has alength()
method to define the required exact length.
2. Creating the Validator Link to heading
Next, we need a validator that can process these annotations:
public class Validator {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");
private static final Pattern IPV4_PATTERN =
Pattern.compile("^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
public static List<String> validate(Object object) {
List<String> validationErrors = new ArrayList<>();
Class<?> clazz = object.getClass();
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
try {
Object value = field.get(object);
if (value == null) { continue; }
// This implementation validates a field for a valid email address
if (field.isAnnotationPresent(Email.class) && value instanceof String) {
Email annotation = field.getAnnotation(Email.class);
String email = (String) value;
if (!EMAIL_PATTERN.matcher(email).matches()) {
validationErrors.add(field.getName() + ": " + annotation.message());
}
}
// Implementation to validate a field as a valid IP address
if (field.isAnnotationPresent(IpAddress.class) && value instanceof String) {
IpAddress annotation = field.getAnnotation(IpAddress.class);
String ip = (String) value;
if (!IPV4_PATTERN.matcher(ip).matches()) {
validationErrors.add(field.getName() + ": " + annotation.message());
}
}
// Validate string length
if (field.isAnnotationPresent(StringLength.class) && value instanceof String) {
StringLength annotation = field.getAnnotation(StringLength.class);
String str = (String) value;
if (str.length() < annotation.min() || str.length() > annotation.max()) {
validationErrors.add(field.getName() + ": " + annotation.message() +
" (min=" + annotation.min() + ", max=" + annotation.max() + ")");
}
}
// Exact string length validation
if (field.isAnnotationPresent(ExactLength.class) && value instanceof String) {
ExactLength annotation = field.getAnnotation(ExactLength.class);
String str = (String) value;
if (str.length() != annotation.length()) {
validationErrors.add(field.getName() + ": " +
annotation.message().replace("{length}",
String.valueOf(annotation.length())));
}
}
} catch (IllegalAccessException e) {
validationErrors.add("Error accessing field " + field.getName() + ": " + e.getMessage());
}
}
return validationErrors;
}
}
The validator:
- Uses reflection to examine fields in the object
- Checks each field for the presence of our validation annotations
- Applies the appropriate validation logic
- Collects and returns any validation errors
Using the Annotations Link to heading
Now let’s see how to use these annotations in a real-world model:
public class UserProfile {
@Email
private String email;
@IpAddress
private String lastLoginIp;
@StringLength(max = 200, min = 5, message = "Address must be between 5 and 200 characters")
private String address;
@ExactLength(length = 5, message = "ZIP code must be exactly 5 digits")
private String zipCode;
}
To validate a UserProfile
instance:
UserProfile user = new UserProfile(
"gaurav@somefakedomain.com",
"10.10.1.1",
"7778 Extraordinary Street, San Jose, CA",
"99881"
);
List<String> validationErrors = Validator.validate(user);
if (validationErrors.isEmpty()) {
System.out.println("User is valid!");
} else {
System.out.println("Validation errors:");
validationErrors.forEach(System.out::println);
}
Java Records and Annotations Link to heading
Java 21 record feature provides a compact synta for declaring classes that are transparent holders for shallowly immutable data. Let’s apply our validation techniques using annotations for field validation
public record CustomerRecord(
@Email
String email,
@StringLength(max = 100)
String name,
@StringLength(max = 200, min = 5)
String address,
@ExactLength(length = 5)
String zipCode,
@IpAddress
String lastAccessIp
) {
// Example method or additional functionality
}
The validator works with records just as it does with regular classes, using reflection to access the fields.
Beyond Basic Validation: Advanced Annotation Techniques Link to heading
While our example focuses on field validation, annotations have many other powerful applications:
1. Compile-Time Processing Link to heading
Using annotation processors, you can generate code, validate constraints, or produce documentation during compilation. This is how frameworks like Lombok can generate boilerplate code based on annotations.
@Builder
@Data
public class Product {
private Long id;
private String name;
private BigDecimal price;
}
The Lombok processor generates getters, setters, builders, and more at compile time.
2. Aspect-Oriented Programming Link to heading
Annotations can define cross-cutting concerns like logging, transaction management, or security:
@Transactional
@LogExecutionTime
public void processOrder(Order order) {
// Order processing business logic here
}
3. Runtime Framework Integration Link to heading
Annotations help frameworks like Spring understand how to treat your code:
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
// Handler logic
}
}
Performance Considerations Link to heading
Reflection-based validation has overhead and shouldn’t be used in performance-critical paths. For high-performance needs, consider:
- Validation at object creation time only
- Caching reflection results
- Using compile-time annotation processing instead of runtime reflection
Comparison with Existing Validation Frameworks Link to heading
Our custom annotations are educational but simplified. In production, consider:
Jakarta Bean Validation (formerly JSR 380) Link to heading
public class User {
@NotNull
@Email
private String email;
@Size(min = 5, max = 200)
private String address;
@Pattern(regexp = "\\d{5}")
private String zipCode;
}
Benefits of established frameworks: Link to heading
- Rich, battle-tested validation rules
- Integration with Java EE/Jakarta EE and frameworks like Spring
- Group validation and validation sequences
- Method-level validation
- Message interpolation with property files for i18n
Conclusion Link to heading
Custom validation annotations offer a powerful way to make your code more declarative, maintainable, and self-documenting. They showcase Java’s powerful meta-programming capabilities!
Whether you create your own annotations or use established frameworks like Jakarta Bean Validation, the annotation-based approach helps keep your validation concerns cleanly separated from your business logic.
In the next part, we’ll explore method annotations with the help of an involved example.