Skip to main content

    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:

    AnnotationPut it onWhat it does
    @OverrideA methodCompile error if it doesn't override a parent method
    @DeprecatedAnythingWarns callers it's obsolete and may be removed
    @SuppressWarningsAnythingSilences a named warning, e.g. ("unchecked")
    @FunctionalInterfaceAn interfaceCompile 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.

    Worked Example: the four built-in annotations
    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"));
        }
    }
    Output
    @Override  : dog.speak() -> Woof!
    @Deprecated: oldFormat() -> v1 (legacy)  (callers warned)
                 newFormat() -> v2 (modern)  (no warning)
    @FunctionalInterface: upper.apply("hi") -> HI
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    2️⃣ 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").

    3️⃣ Meta-Annotations — Annotations on Annotations

    A meta-annotation describes how your annotation behaves. These two are the ones you must get right:

    Meta-annotationControlsCommon values
    @RetentionHow long it survivesSOURCE, CLASS (default), RUNTIME
    @TargetWhere it may be usedTYPE, METHOD, FIELD, PARAMETER, ...
    @DocumentedAppears in JavadocMarker (no value)
    @InheritedSubclasses inherit itClass-level annotations only

    @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.

    Worked Example: define @Test, then read it with reflection
    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);
        }
    }
    Output
    PASS  addition
    SKIP  broken
    ---
    ran=1 passed=1 skipped=1
    This is real Java — run it free at onecompiler.com/java. getDeclaredMethods() 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.

    🎯 Your Turn #1
    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
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 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.

    🎯 Your Turn #2
    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
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🧩 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.

    🧩 Mini-Challenge
    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
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors

    • Wrong retention — annotation invisible at runtime. You define an annotation and getAnnotation() always returns null. 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 declaration means you put the annotation somewhere its @Target forbids — e.g. a @Target(ElementType.METHOD) annotation placed on a field. Fix: add the right ElementType to @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 add default to its declaration to make it optional.
    • Confusing @interface with interface. Writing interface Test (no @) defines a regular interface, and @Test above a method won't compile. Fix: use @interface to declare an annotation.
    • Non-constant element value. error: element value must be a constant expression happens with @Test(name = someVariable). Fix: pass a literal, enum, or X.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 a SOURCE-retention annotation processor to generate code at compile time.

    📋 Quick Reference

    TaskSyntaxNotes
    Define annotation@interface Name { }Note the @ — not plain interface
    Element + defaultString 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 annotationm.getAnnotation(X.class)null if absent
    Check presencem.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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.