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:

  1. Reducing boilerplate code: Place an annotation directly where it’s relevant
  2. Improving code readability: Express intention clearly at the point of declaration
  3. Supporting compile-time checking: Many annotation errors can be caught early
  4. Enabling powerful tooling: Code generation, validation, and documentation

Types of Java Annotations Link to heading

Java annotations generally fall into three categories:

  1. Marker annotations: Simple annotations that mark a declaration for special handling

    @Override
    public void toString() { ... }
    
  2. Single-value annotations: Carry a single piece of data

    @SuppressWarnings("unchecked")
    public List getItems() { ... }
    
  3. 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:

  1. Declarative validation: Define what’s valid directly where the field is declared
  2. Separation of concerns: Keep validation logic separate from business logic
  3. Reusability: Define validations once and use them throughout your codebase
  4. Self-documenting code: The validation requirements are visible in the code
  5. 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 has min() and max() methods to define the allowed range.
  • ExactLength has a length() 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:

  1. Uses reflection to examine fields in the object
  2. Checks each field for the presence of our validation annotations
  3. Applies the appropriate validation logic
  4. 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:

  1. Validation at object creation time only
  2. Caching reflection results
  3. 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

  1. Rich, battle-tested validation rules
  2. Integration with Java EE/Jakarta EE and frameworks like Spring
  3. Group validation and validation sequences
  4. Method-level validation
  5. 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.

Resources Link to heading