Skip to content

Java Annotations & Reflection — Complete Guide

DodaTech Updated 2026-06-20 10 min read

In this tutorial, you'll learn about Java Annotations & Reflection. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Java annotations are metadata tags that provide information to compilers and frameworks, enabling declarative programming without modifying actual code logic.

Why Annotations & Reflection Matter

Annotations eliminate boilerplate. Instead of writing endless XML configuration or repetitive code, you annotate once and let frameworks do the work. JUnit 5 uses @Test. Jakarta EE uses @Inject, @Path, @Entity. Spring uses @Autowired, @Controller, @Transactional. Reflection reads these annotations at runtime to wire dependencies, map HTTP routes, validate data, and more. DodaTech's Doda Browser plugin system uses custom annotations to register extensions — a third-party developer writes @BrowserPlugin(id = "adblock") and reflection handles the rest.

Learning Path

graph LR
    A[Java Basics] --> B[Annotations Fundamentals]
    B --> C[Creating Custom Annotations]
    C --> D[Retention & Target Policies]
    D --> E[Reflection API]
    E --> F[Runtime Annotation Processing]
    F --> G[Framework Building]
    style C fill:#f59e0b,color:#fff,stroke-width:3px

What Are Annotations?

Think of annotations like sticky notes on a document. The document itself doesn't change, but the sticky notes tell the reader what to do — "sign here", "see page 42", "draft version". Similarly, @Override tells the compiler "this method should override a parent method". @Deprecated says "don't use this anymore". @SuppressWarnings says "ignore this warning".

Java has three annotation retention policies that control how long the annotation lives:

Policy Lifetime Use Case
SOURCE Discarded after compilation @Override, @SuppressWarnings
CLASS Stored in .class file but not available at runtime Bytecode processing tools
RUNTIME Available at runtime via reflection @Test, @Inject, @Entity

Most custom annotations use RUNTIME so frameworks can read them with reflection.

Creating a Custom Annotation

Let's build a @JsonField annotation that tells a serializer how to map a Java field to JSON.

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface JsonField {
    String name() default "";
    boolean required() default false;
}

@Retention(RUNTIME) ensures the annotation survives to runtime. @Target(FIELD) restricts it to fields only (using it on a class causes a compile error). name() and required() are annotation elements — like methods but for metadata. default provides fallback values so users can omit them.

Now let's use it:

public class UserProfile {
    @JsonField(name = "user_id", required = true)
    private Long id;
    
    @JsonField(name = "display_name")
    private String name;
    
    @JsonField(name = "email", required = true)
    private String email;
    
    private String internalNotes; // not annotated, won't be serialized
    
    public UserProfile(Long id, String name, String email, String notes) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.internalNotes = notes;
    }
}

Only fields with @JsonField are included in Serialization. internalNotes is excluded — a security feature that prevents accidental data leaks.

Processing Annotations with Reflection

Now the magic: a serializer reads these annotations at runtime.

import java.lang.reflect.Field;
import java.util.*;

public class JsonSerializer {
    
    public String toJson(Object obj) throws Exception {
        StringBuilder json = new StringBuilder("{");
        boolean first = true;
        
        for (Field field : obj.getClass().getDeclaredFields()) {
            JsonField annotation = field.getAnnotation(JsonField.class);
            if (annotation == null) continue;
            
            field.setAccessible(true);
            Object value = field.get(obj);
            
            if (annotation.required() && value == null) {
                throw new IllegalArgumentException(
                    "Required field '" + field.getName() + "' is null");
            }
            
            if (!first) json.append(",");
            String jsonName = annotation.name().isEmpty() 
                ? field.getName() : annotation.name();
            
            json.append("\"").append(jsonName).append("\":");
            if (value instanceof String) {
                json.append("\"").append(value).append("\"");
            } else {
                json.append(value);
            }
            first = false;
        }
        
        json.append("}");
        return json.toString();
    }
}

getDeclaredFields() returns all fields including private ones. getAnnotation(JsonField.class) checks for our annotation. field.setAccessible(true) bypasses Java's access control so we can read private fields — this is why reflection is called "reflective": it lets code inspect itself. The required check throws if a mandatory field is null, catching data integrity issues at Serialization time.

Expected output:

{"user_id":42,"display_name":"Alice","email":"alice@example.com"}

If required=true and value is null:

Exception: Required field 'id' is null

Reflection — Inspecting Classes Dynamically

Reflection isn't just for annotations. It can inspect class methods, constructors, and supertypes at runtime — useful for plugin systems, DI containers, and Serialization libraries.

import java.lang.reflect.*;

public class ClassInspector {
    
    public static void inspect(Class<?> clazz) {
        System.out.println("Class: " + clazz.getName());
        System.out.println("Package: " + clazz.getPackageName());
        System.out.println("Superclass: " + clazz.getSuperclass().getSimpleName());
        
        System.out.println("\n--- Annotations ---");
        for (Annotation a : clazz.getAnnotations()) {
            System.out.println("  @" + a.annotationType().getSimpleName());
        }
        
        System.out.println("\n--- Fields ---");
        for (Field f : clazz.getDeclaredFields()) {
            System.out.printf("  %s %s%n", f.getType().getSimpleName(), f.getName());
        }
        
        System.out.println("\n--- Methods ---");
        for (Method m : clazz.getDeclaredMethods()) {
            System.out.printf("  %s %s(%s)%n",
                m.getReturnType().getSimpleName(),
                m.getName(),
                Arrays.toString(m.getParameterTypes()));
        }
    }
    
    public static void main(String[] args) {
        inspect(UserProfile.class);
    }
}

Class<?> is the entry point to reflection. Every object in Java knows its class. getAnnotations() returns all runtime-retained annotations. getDeclaredMethods() returns methods declared in this class (not inherited). This introspection power is what makes frameworks like Spring, Hibernate, and JUnit possible.

Expected output:

Class: UserProfile
Package: com.dodatech
Superclass: Object

--- Annotations ---

--- Fields ---
  Long id
  String name
  String email
  String internalNotes

--- Methods ---
  String toString()
  Long getId()
  void setId(Long)

Building a Mini Dependency Injection Framework

Let's combine annotations and reflection to build a simple DI container.

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

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Inject {}

class EmailService {
    public void send(String to, String message) {
        System.out.println("Email sent to " + to + ": " + message);
    }
}

class UserRegistration {
    @Inject
    private EmailService emailService;
    
    public void register(String email) {
        System.out.println("Registering user: " + email);
        emailService.send(email, "Welcome!");
    }
}

class DIContainer {
    private Map<Class<?>, Object> instances = new HashMap<>();
    
    public <T> T register(Class<T> clazz) throws Exception {
        T instance = clazz.getDeclaredConstructor().newInstance();
        instances.put(clazz, instance);
        injectDependencies(instance);
        return instance;
    }
    
    private void injectDependencies(Object instance) throws Exception {
        for (Field field : instance.getClass().getDeclaredFields()) {
            if (field.isAnnotationPresent(Inject.class)) {
                Class<?> dependencyType = field.getType();
                Object dependency = instances.computeIfAbsent(
                    dependencyType, this::createInstance);
                field.setAccessible(true);
                field.set(instance, dependency);
            }
        }
    }
    
    private Object createInstance(Class<?> clazz) {
        try {
            Object obj = clazz.getDeclaredConstructor().newInstance();
            injectDependencies(obj);
            return obj;
        } catch (Exception e) {
            throw new RuntimeException("Cannot create " + clazz, e);
        }
    }
}

public class DIContainerDemo {
    public static void main(String[] args) throws Exception {
        DIContainer container = new DIContainer();
        container.register(EmailService.class);
        UserRegistration registration = container.register(UserRegistration.class);
        registration.register("alice@example.com");
    }
}

The container scans fields for @Inject, finds the matching dependency type, creates it if needed, and sets the field via reflection. This is exactly how Spring and Jakarta CDI work internally — just with more features (scopes, qualifiers, lifecycle callbacks).

Expected output:

Registering user: alice@example.com
Email sent to alice@example.com: Welcome!

Common Errors in Annotations & Reflection

Error Cause Fix
AnnotationTypeMismatchException Annotation element value doesn't match declared type Ensure all annotation values match the declared element types
IncompleteAnnotationException Annotation missing an element that has no default Provide a default value or require users to specify the element
IllegalAccessException setAccessible(true) not called or security manager blocks it Call field.setAccessible(true) before get()/set() or configure security policy
NoSuchMethodException Calling getDeclaredMethod() with wrong parameter types Use exact parameter types or getDeclaredMethods() to discover them
ClassNotFoundException Class name string doesn't match a loaded class Use fully qualified name, check classpath and module exports
AnnotationFormatError Malformed annotation in bytecode Recompile the source file — bytecode might be from a different version
InaccessibleObjectException (Java 9+) Module system hides reflective access Add --add-opens JVM flag or use opens directive in module-info.java

Security Angle: Annotation-Based Access Control

Reflection can read method annotations at runtime to enforce security. This is how DodaBrowser's plugin API restricts sensitive operations:

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

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface RequiresPermission {
    String value();
}

class SecureService {
    
    @RequiresPermission("admin")
    public void deleteUser(long userId) {
        System.out.println("User " + userId + " deleted");
    }
    
    @RequiresPermission("user")
    public void viewProfile(long userId) {
        System.out.println("Viewing profile " + userId);
    }
}

class SecurityProxy {
    private final Set<String> userPermissions;
    
    public SecurityProxy(Set<String> permissions) {
        this.userPermissions = permissions;
    }
    
    public Object invoke(Object target, Method method, Object... args) throws Exception {
        RequiresPermission perm = method.getAnnotation(RequiresPermission.class);
        if (perm != null && !userPermissions.contains(perm.value())) {
            throw new SecurityException(
                "Access denied: need '" + perm.value() + "' permission");
        }
        return method.invoke(target, args);
    }
}

This pattern — method annotations read by a proxy — is the foundation of Java's security framework. Durga Antivirus Pro uses it to control which plugins can access file system, network, and process management APIs.

Practice Questions

  1. What is the difference between RetentionPolicy.RUNTIME and RetentionPolicy.SOURCE?
  2. How does field.setAccessible(true) work and why is it needed?
  3. What is the purpose of @Target in annotation declaration?
  4. How does reflection enable Dependency Injection frameworks?
  5. Why did Java 9+ add restrictions on reflection via the module system?

Answers:

  1. SOURCE annotations are discarded during compilation (like @Override). RUNTIME annotations are preserved in bytecode and accessible via reflection at runtime (like @Test). Only RUNTIME annotations can be processed by frameworks.
  2. setAccessible(true) overrides Java's access control checks for that specific field, allowing private field access via reflection. It's needed because frameworks need to read/write private fields for Serialization and DI.
  3. @Target restricts where an annotation can be applied: FIELD (fields only), METHOD (methods only), TYPE (classes/interfaces), PARAMETER (method parameters). Using it on the wrong element causes a compile error.
  4. Reflection provides getDeclaredFields(), getAnnotation(), and field.set(). A DI container scans a class's fields for @Inject, creates the required dependencies, and injects them — all without the class knowing about the container.
  5. The module system (JPMS) adds strong Encapsulation. By default, reflective access to private members is blocked unless the module explicitly opens packages or the caller has --add-opens flags. This improves security by preventing unauthorized reflective access.

Challenge

Build a @Route(path = "/users", method = "GET") annotation processor that:

  1. Scans a package for classes with @Route
  2. Extracts the path and HTTP method from the annotation
  3. Registers the handler method in a simple HTTP router
  4. Uses reflection to invoke the correct handler when a request arrives
  5. Throws 404 if no matching route is found

Real-World Task: Custom Validation Framework

Create a validation framework with annotations like @NotNull, @MinLength(5), @Email, and @Matches("regex"). Process them with reflection to validate any object before saving to a database. This mirrors DodaTech's form validation system in Doda Browser — every user input is validated through annotation-driven rules before reaching the backend.

What is the difference between annotations and comments?

Comments are ignored by the compiler and runtime — they're only for humans. Annotations are processed by tools and frameworks. @Override causes a compile error if the method doesn't actually override a parent method. @Test tells JUnit to run the method as a test. Comments can't trigger any behavior.

Does reflection impact performance?

Yes — reflection is slower than direct method calls because it performs access checks, boxing, and array allocations. However, for most use cases (annotation scanning at startup, DI container initialization), the overhead is negligible. Avoid reflection in hot paths or cache reflection data with Class.getDeclaredFields() at startup.

Why does my annotation not show up at runtime?

You forgot @Retention(RetentionPolicy.RUNTIME). The default is CLASS, which stores the annotation in the .class file but doesn't make it accessible via reflection. Without RUNTIME, getAnnotation() always returns null.

Related tutorials: Java I/O — File Handling & NIO Guide, Java Build Tools — Maven & Gradle Guide

Next lesson: Java Module System (JPMS) — Complete Guide

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro