🚀AdvancedReflection and Metaprogramming

Custom Annotations

Define and process custom annotations with annotation processors and the Reflection API.

Overview

Annotations are metadata attached to code elements (classes, methods, fields, parameters) that can be read by tools, frameworks, and runtime processors. You have used built-in annotations like @Override, @Deprecated, and @FunctionalInterface. Java also lets you create your own. Custom annotations power frameworks: @Test in JUnit, @Entity in JPA, @GetMapping in Spring MVC are all annotations processed at compile time or runtime to drive framework behavior. To create a custom annotation, you use @interface. You then combine meta-annotations to specify where it can be applied (@Target) and how long it lives (@Retention).

Key Points

  • •@interface declares a custom annotation type
  • •@Retention(RUNTIME) keeps the annotation available via reflection at runtime
  • •@Retention(CLASS) retains in bytecode but not at runtime (used by tools)
  • •@Retention(SOURCE) discarded after compilation (used by IDE tools)
  • •@Target(ElementType.METHOD) restricts where the annotation can be applied
  • •Annotation elements look like methods: String value() default "default";
  • •An annotation with a single element named value() allows shorthand: @MyAnnotation("text")
  • •Repeatable annotations use @Repeatable(Container.class) and a container annotation
  • •Process annotations at runtime with reflection: method.getAnnotation(MyAnnotation.class)

Examples

Defining and reading a custom @Loggable annotation

import java.lang.annotation.*;
import java.lang.reflect.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Loggable {
    String level() default "INFO";
}

class UserService {
    @Loggable
    public void createUser(String name) {
        System.out.println("Creating user: " + name);
    }

    @Loggable(level = "DEBUG")
    public void deleteUser(String name) {
        System.out.println("Deleting user: " + name);
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        UserService service = new UserService();

        for (Method method : UserService.class.getDeclaredMethods()) {
            Loggable annotation = method.getAnnotation(Loggable.class);
            if (annotation != null) {
                System.out.println("[" + annotation.level() + "] Calling: " + method.getName());
                method.invoke(service, "Alice");
            }
        }
    }
}

The annotation processor loops over methods, finds @Loggable, logs at the specified level, then invokes the method. This is exactly how frameworks like Spring AOP and JUnit work.

Validation annotation: @NotEmpty for fields

import java.lang.annotation.*;
import java.lang.reflect.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface NotEmpty {
    String message() default "Field must not be empty";
}

class UserForm {
    @NotEmpty
    private String username;

    @NotEmpty(message = "Email is required")
    private String email;

    private String bio; // no validation

    UserForm(String username, String email, String bio) {
        this.username = username;
        this.email = email;
        this.bio = bio;
    }
}

class Validator {
    public static void validate(Object obj) throws Exception {
        for (Field field : obj.getClass().getDeclaredFields()) {
            NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
            if (notEmpty != null) {
                field.setAccessible(true);
                Object value = field.get(obj);
                if (value == null || value.toString().isEmpty()) {
                    throw new IllegalArgumentException(notEmpty.message() + " [" + field.getName() + "]");
                }
            }
        }
        System.out.println("Validation passed");
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        Validator.validate(new UserForm("alice", "alice@example.com", null));
        Validator.validate(new UserForm("", "alice@example.com", null)); // throws
    }
}

This is a simplified version of what Bean Validation (JSR 380) does with @NotNull, @NotEmpty, and @Size. Production validators also check constraint groups and collect all violations rather than stopping at the first one.

Repeatable annotations

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(Roles.class)
@interface Role {
    String value();
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Roles {
    Role[] value();
}

class AdminService {
    @Role("ADMIN")
    @Role("SUPERUSER")
    public void deleteEverything() {
        System.out.println("Deleting...");
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        var method = AdminService.class.getMethod("deleteEverything");
        Role[] roles = method.getAnnotationsByType(Role.class);
        for (Role r : roles) {
            System.out.println("Requires role: " + r.value());
        }
    }
}

Repeatable annotations require both the annotation itself and a container annotation. getAnnotationsByType unwraps the container transparently.

Common Mistakes

  • Using @Retention(SOURCE) when you need runtime access. SOURCE annotations are discarded by the compiler. Use RUNTIME if you plan to read the annotation with reflection at runtime.
  • Forgetting @Target and letting annotations be placed anywhere. Always restrict with @Target to catch misuse at compile time.
  • Confusing annotation elements with fields. Annotation elements are declared like methods (String value();), not like fields. You cannot have logic or state in an annotation.
  • Reading annotations on methods obtained from interfaces. Annotations are not inherited by default when overriding. Check AnnotatedElement.isAnnotationPresent() on the actual implementation class.

Note

For compile-time annotation processing (code generation, validation without runtime overhead), look into the javax.annotation.processing.Processor API. Libraries like Lombok, MapStruct, and Dagger use compile-time processors to generate boilerplate code from annotations.

Exercises

Write Java code and click Run to execute it instantly. When your output looks correct, mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

Which @Retention policy must you use if you want to read an annotation at runtime using reflection?