Lesson 32 • Advanced
Java Annotations
By the end of this lesson you'll be able to use Java's built-in annotations correctly, define your own with @interface, control them with meta-annotations, and read them back at runtime with reflection — the exact machinery behind Spring, JPA, and JUnit.
Before You Start
You should be comfortable with the Reflection API (reading classes and methods at runtime) and Interfaces (because annotations are declared with the similar-looking @interface keyword). No Spring or JUnit experience needed — you'll build a tiny version of each here.
What You'll Learn
- ✓Use the built-in annotations: @Override, @Deprecated, @SuppressWarnings, @FunctionalInterface
- ✓Define your own annotation with @interface
- ✓Control behaviour with meta-annotations: @Retention, @Target, @Documented, @Inherited
- ✓Add elements with default values and read them back
- ✓Read annotations at runtime with the Reflection API
- ✓Pick the right @Retention so frameworks can actually see your annotation
The Big Idea — Sticky Notes for Your Code
💡 Analogy: An annotation is a sticky note you attach to your code. The note doesn't change what the code does — it's an instruction for whoever reads it later. @Override is a note to the compiler: "double-check this overrides something." @Test is a note to JUnit: "run this as a test." @Entity is a note to Hibernate: "this maps to a database table."
Annotations start with @ and sit directly above the thing they describe — a class, method, field, or parameter. On their own they are passive metadata. Something else — the compiler, a framework, or your own reflection code — has to read the note and act on it. This lesson covers both halves: writing the notes, and reading them.
1️⃣ Built-in Annotations
Java ships with a handful of annotations the compiler already understands. You'll use these four constantly:
| Annotation | Put it on | What it does |
|---|---|---|
| @Override | A method | Compile error if it doesn't override a parent method |
| @Deprecated | Anything | Warns callers it's obsolete and may be removed |
| @SuppressWarnings | Anything | Silences a named warning, e.g. ("unchecked") |
| @FunctionalInterface | An interface | Compile error unless it has exactly one abstract method |
The key thing: these are checked by the compiler, not at runtime. They catch mistakes early. The worked example below uses all four — read it, then run it.
public class Main {
static class Animal {
String speak() { return "..."; }
}
static class Dog extends Animal {
@Override // compiler verifies this really overrides Animal.speak()
String speak() { return "Woof!"; }
}
@Deprecated // anyone who calls this gets a compiler warning
static String oldFormat() { return "v1 (legacy)"; }
static String newFormat() { return "v2 (modern)"; }
// @FunctionalInterface enforces EXACTLY ONE abstract method at compile time
@FunctionalInterface
interface Transformer {
String apply(String input);
}
public static void main(String[] args) {
// @SuppressWarnings silences a specific warning for this one statement.
@SuppressWarnings("unused")
int debugCounter = 0; // we never read it; the warning is hidden
Animal dog = new Dog();
System.out.println("@Override : dog.speak() -> " + dog.speak());
System.out.println("@Deprecated: oldFormat() -> " + oldFormat() + " (callers warned)");
System.out.println(" newFormat() -> " + newFormat() + " (no warning)");
// Because Transformer is a @FunctionalInterface we can use a lambda:
Transformer upper = String::toUpperCase;
System.out.println("@FunctionalInterface: upper.apply(\"hi\") -> " + upper.apply("hi"));
}
}@Override : dog.speak() -> Woof!
@Deprecated: oldFormat() -> v1 (legacy) (callers warned)
newFormat() -> v2 (modern) (no warning)
@FunctionalInterface: upper.apply("hi") -> HI2️⃣ Defining Your Own Annotation
You create an annotation with @interface (one word, with the @). Inside, each "method" declares an element — a piece of data the annotation can carry. Give an element a default and it becomes optional.
@interface Test {
String name() default "unnamed"; // optional — has a default
boolean enabled() default true; // optional — has a default
String[] tags(); // REQUIRED — no default, caller must supply
}Applying it looks like a function call: @Test(name = "addition", tags = {"math"}). If an annotation has a single element named value, you can drop the name: @Role("ADMIN") instead of @Role(value = "ADMIN").
X.class), other annotations, or arrays of those. You cannot pass a variable or a method call.3️⃣ Meta-Annotations — Annotations on Annotations
A meta-annotation describes how your annotation behaves. These two are the ones you must get right:
| Meta-annotation | Controls | Common values |
|---|---|---|
| @Retention | How long it survives | SOURCE, CLASS (default), RUNTIME |
| @Target | Where it may be used | TYPE, METHOD, FIELD, PARAMETER, ... |
| @Documented | Appears in Javadoc | Marker (no value) |
| @Inherited | Subclasses inherit it | Class-level annotations only |
@Retention(RetentionPolicy.RUNTIME). The default is CLASS, which throws the annotation away before your program runs — so getAnnotation() returns null and nothing works.@Inherited means: if a parent class carries the annotation, subclasses are treated as if they carry it too — but only for annotations placed on classes, not on methods or fields.
import java.lang.annotation.*;
import java.lang.reflect.Method;
public class Main {
// Defining an annotation: @interface (NOT interface).
// The meta-annotations decide HOW this annotation behaves:
@Retention(RetentionPolicy.RUNTIME) // keep it at runtime so reflection can read it
@Target(ElementType.METHOD) // it may only be placed on methods
@Documented // include it in generated Javadoc
@interface Test {
String name() default "unnamed"; // an element with a default value
boolean enabled() default true; // omit it when applying -> uses true
}
static class Calculator {
@Test(name = "addition") // enabled defaults to true
boolean checkAdd() { return 2 + 2 == 4; }
@Test(name = "broken", enabled = false) // explicitly disabled
boolean checkBug() { return 1 == 2; }
boolean helper() { return true; } // no @Test -> reflection skips it
}
public static void main(String[] args) throws Exception {
int run = 0, passed = 0, skipped = 0;
// Loop over the methods and read the annotation at RUNTIME.
for (Method m : Calculator.class.getDeclaredMethods()) {
Test t = m.getAnnotation(Test.class); // null if @Test is absent
if (t == null) continue; // not a test method
if (!t.enabled()) { // read the element value
System.out.println("SKIP " + t.name());
skipped++;
continue;
}
run++;
boolean ok = (boolean) m.invoke(new Calculator()); // call the method
if (ok) passed++;
System.out.println((ok ? "PASS " : "FAIL ") + t.name());
}
System.out.println("---");
System.out.println("ran=" + run + " passed=" + passed + " skipped=" + skipped);
}
}PASS addition
SKIP broken
---
ran=1 passed=1 skipped=1getDeclaredMethods() has no guaranteed order, so the PASS/SKIP lines may appear in a different order.4️⃣ Reading Annotations at Runtime
This is where annotations earn their keep. Reflection lets you walk a class's methods and fields and ask each one "do you have this annotation?" — exactly how a framework discovers what to do.
for (Method m : MyClass.class.getDeclaredMethods()) {
Test t = m.getAnnotation(Test.class); // null if @Test is not present
if (t != null) { // it's there
System.out.println(t.name()); // read an element value
}
}The same pattern works for classes (MyClass.class.getAnnotation(...)) and fields (field.getAnnotation(...)). Use isAnnotationPresent(X.class) when you only need a yes/no and don't care about the values.
🎯 Your Turn #1 — Define an annotation
Fill in the three blanks so @Route is readable at runtime, restricted to methods, and gives method a default of "GET". Run it and check the output.
import java.lang.annotation.*;
import java.lang.reflect.Method;
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Make this annotation readable via reflection at runtime
@Retention(RetentionPolicy.___) // 👉 SOURCE, CLASS, or RUNTIME?
// 2) Restrict it so it can ONLY go on methods
@Target(ElementType.___) // 👉 TYPE, METHOD, FIELD, ...?
@interface Route {
// 3) Give "method" a default of "GET"
String method() default ___; // 👉 a String literal in "double quotes"
String path(); // no default -> caller MUST supply it
}
static class Api {
@Route(path = "/home") // method uses the default
void home() {}
@Route(method = "POST", path = "/login") // method overrides the default
void login() {}
}
public static void main(String[] args) {
for (Method m : Api.class.getDeclaredMethods()) {
Route r = m.getAnnotation(Route.class);
if (r != null) {
System.out.println(r.method() + " " + r.path());
}
}
}
// ✅ Expected output (order may vary — getDeclaredMethods has no fixed order):
// GET /home
// POST /login
}🎯 Your Turn #2 — Read an annotation
The @Role annotation is already defined. Fill in the two blanks to read it off each method and print only the methods that carry it.
import java.lang.annotation.*;
import java.lang.reflect.Method;
public class Main {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Role {
String value(); // single element named "value"
}
static class Admin {
@Role("ADMIN") void deleteUser() {}
@Role("USER") void viewProfile() {}
void ping() {} // no @Role
}
public static void main(String[] args) throws Exception {
for (Method m : Admin.class.getDeclaredMethods()) {
// 🎯 YOUR TURN — read the @Role annotation off this method
// 1) Get the annotation (will be null when @Role is absent)
Role r = m.___(Role.class); // 👉 which Method method reads ONE annotation?
// 2) Only print methods that actually have @Role
if (r ___ null) { // 👉 "is not equal to" operator
System.out.println(m.getName() + " requires role " + r.value());
}
}
}
// ✅ Expected output (order may vary):
// deleteUser requires role ADMIN
// viewProfile requires role USER
}🧩 Mini-Challenge — A tiny @NotNull validator
Support is faded now — only a comment outline is given. Build a field-level annotation and a reflection loop that checks required fields, like Bean Validation does. The expected output is in the comments.
import java.lang.annotation.*;
import java.lang.reflect.Field;
public class Main {
// 🎯 MINI-CHALLENGE: a tiny "@NotNull required field" validator
//
// 1. Define an annotation @NotNull
// - @Retention(RUNTIME) (so reflection can see it)
// - @Target(ElementType.FIELD) (it goes on fields)
// 2. Make a class User with two String fields:
// - @NotNull String username (set it to "ada")
// - String nickname (leave it null, no annotation)
// 3. In main: loop over User.class.getDeclaredFields().
// For each field that has @NotNull, read its value (f.setAccessible(true);
// f.get(userInstance)). If the value is null, print an error line.
//
// ✅ Expected output (username="ada", nickname=null):
// Validating User...
// OK: username
// (no error, because the only @NotNull field is set)
// your code here
}Common Errors
- ❌ Wrong retention — annotation invisible at runtime. You define an annotation and
getAnnotation()always returnsnull. The cause is the default@Retention(RetentionPolicy.CLASS), which discards it before runtime. Fix: add@Retention(RetentionPolicy.RUNTIME)to the@interface. - ❌ Wrong target — won't compile.
error: annotation type not applicable to this kind of declarationmeans you put the annotation somewhere its@Targetforbids — e.g. a@Target(ElementType.METHOD)annotation placed on a field. Fix: add the rightElementTypeto@Target(you can list several:{METHOD, FIELD}) or move the annotation. - ❌ Forgetting a default on a mandatory element.
error: annotation @Test is missing a default value for the element 'tags'appears when you apply the annotation without supplying a non-default element. Fix: either pass the element when you use it, or adddefaultto its declaration to make it optional. - ❌ Confusing
@interfacewithinterface. Writinginterface Test(no@) defines a regular interface, and@Testabove a method won't compile. Fix: use@interfaceto declare an annotation. - ❌ Non-constant element value.
error: element value must be a constant expressionhappens with@Test(name = someVariable). Fix: pass a literal, enum, orX.class— never a variable or method call.
Where You've Already Seen This
- 💡 Spring:
@Component,@Autowired,@RequestMapping,@Transactional— the framework scans for these with the same reflection you just used. - 💡 JPA / Hibernate:
@Entity,@Table,@Column,@Id— map Java objects to database tables. - 💡 JUnit 5:
@Test,@BeforeEach,@DisplayName— your worked example was a miniature version of this. - 💡 Lombok:
@Data,@Builder,@Getter— these use aSOURCE-retention annotation processor to generate code at compile time.
📋 Quick Reference
| Task | Syntax | Notes |
|---|---|---|
| Define annotation | @interface Name { } | Note the @ — not plain interface |
| Element + default | String name() default "x"; | default makes it optional |
| Keep at runtime | @Retention(RetentionPolicy.RUNTIME) | Required for reflection |
| Restrict placement | @Target(ElementType.METHOD) | TYPE, FIELD, PARAMETER, ... |
| Read one annotation | m.getAnnotation(X.class) | null if absent |
| Check presence | m.isAnnotationPresent(X.class) | Returns a boolean |
| Apply with values | @Test(name = "add") | Looks like a method call |
❓ Frequently Asked Questions
What is the difference between @interface and interface in Java?
interface defines a contract of methods a class must implement. @interface (with the @ symbol) defines an annotation — a type used to attach metadata to code. They look similar but are completely different: you implement an interface, but you apply an annotation with @Name above a class, method, or field.
Why can't I read my custom annotation with reflection?
Almost always because the annotation is missing @Retention(RetentionPolicy.RUNTIME). The default retention is CLASS, which keeps the annotation in the .class file but discards it before runtime, so getAnnotation() returns null. Add @Retention(RetentionPolicy.RUNTIME) to your @interface and reflection will see it.
What does @Override actually do if it doesn't change behaviour?
@Override is a compile-time safety check. It tells the compiler 'this method is meant to override one from a parent class or interface.' If it doesn't — for example you misspell the name or get the parameters wrong — you get a compile error instead of a silent bug where your method is never called.
What is the difference between @Target and @Retention?
@Target controls WHERE an annotation may be applied (METHOD, TYPE, FIELD, PARAMETER, and so on). @Retention controls HOW LONG it survives (SOURCE = discarded after compile, CLASS = kept in the bytecode but not at runtime, RUNTIME = readable via reflection). Use the wrong @Target and the code won't compile; use the wrong @Retention and reflection silently can't find it.
Do I need to give annotation elements default values?
No, but it is good practice. An element without a default is mandatory — every use of the annotation must supply it, or the code won't compile. Adding default makes the element optional, so callers can omit it. Use String name() default "unnamed"; to make name optional with a fallback.
🎉 Lesson Complete!
You can now read the built-in annotations the compiler enforces, define your own with @interface, steer them with @Retention and @Target, give elements defaults, and pull them back out at runtime with reflection. That last loop — scan, read, act — is the whole secret behind Spring, JPA, and JUnit.
Next up: IO & NIO — reading and writing files, streams, and non-blocking I/O.
Sign up for free to track which lessons you've completed and get learning reminders.