Java Annotations & Reflection — Complete Guide
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
- What is the difference between
RetentionPolicy.RUNTIMEandRetentionPolicy.SOURCE? - How does
field.setAccessible(true)work and why is it needed? - What is the purpose of
@Targetin annotation declaration? - How does reflection enable Dependency Injection frameworks?
- Why did Java 9+ add restrictions on reflection via the module system?
Answers:
SOURCEannotations are discarded during compilation (like@Override).RUNTIMEannotations are preserved in bytecode and accessible via reflection at runtime (like@Test). OnlyRUNTIMEannotations can be processed by frameworks.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.@Targetrestricts 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.- Reflection provides
getDeclaredFields(),getAnnotation(), andfield.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. - The module system (JPMS) adds strong Encapsulation. By default, reflective access to private members is blocked unless the module explicitly
openspackages or the caller has--add-opensflags. This improves security by preventing unauthorized reflective access.
Challenge
Build a @Route(path = "/users", method = "GET") annotation processor that:
- Scans a package for classes with
@Route - Extracts the path and HTTP method from the annotation
- Registers the handler method in a simple HTTP router
- Uses reflection to invoke the correct handler when a request arrives
- 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.
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