Annotations

Metadata that drives compilers, build tools, and every framework you use

← Back to Index

What Are Annotations?

An annotation is metadata attached to code — a class, method, field, or parameter — that doesn't change what the code does by itself, but provides information that a tool can act on: the compiler, a build tool, a framework, or your own code via reflection.

The problem they solve: before annotations (Java 5, 2004), configuration lived in separate XML files disconnected from the code they configured — error-prone and hard to keep in sync. Annotations put configuration directly next to what it configures, type-checked by the compiler.

@AnnotationName
public class MyClass {
    @AnnotationName
    private String field;

    @AnnotationName
    public void method() { }
}

Built-In Annotations

// @Override — compiler verifies this actually overrides a parent method
class Dog extends Animal {
    @Override
    public void makeSound() { ... }  // ✅ correctly overrides

    @Override
    public void makeSoud() { ... }  // ❌ compile error: typo caught immediately
}

// @Deprecated — warns callers, doesn't break compilation
@Deprecated(since = "2.0", forRemoval = true)  // since/forRemoval added Java 9+
public int add(int a, int b) { return a + b; }

// @SuppressWarnings — silences specific compiler warnings, scope it tightly
@SuppressWarnings("unchecked")
List raw = legacyApi.getRawList();  // known-safe cast from legacy code

// @FunctionalInterface — compiler enforces exactly one abstract method
@FunctionalInterface
public interface Calculator {
    int calculate(int a, int b);  // adding a 2nd abstract method = compile error
}

// @SafeVarargs — asserts the varargs array isn't misused (heap pollution)
@SafeVarargs
public static <T> void printAll(T... items) { ... }

Creating Custom Annotations

An annotation is declared with @interface. Its behaviour is controlled by meta-annotations — annotations that annotate your annotation.

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)  // available via reflection at runtime
@Target(ElementType.METHOD)          // only valid on methods
@Documented                          // appears in generated Javadoc
public @interface Test {
    String name() default "Test";
    int    timeout() default 5000;
    boolean enabled() default true;
}

// Usage — defaults can be omitted
@Test
public void simpleTest() { }

@Test(name = "Custom", timeout = 10000)
public void customTest() { }

// Special parameter name "value" enables shorthand: @Version("1.0")
public @interface Version { String value(); }
@Version("1.0")  // instead of @Version(value = "1.0")
public class Product { }

The four meta-annotations

Meta-annotation Controls Key values
@Retention How long the annotation survives SOURCE (compiler only), CLASS (default, in .class but not at runtime), RUNTIME (readable via reflection — what you need 95% of the time)
@Target Where it can be applied TYPE, METHOD, FIELD, PARAMETER, CONSTRUCTOR — combine with { } for multiple
@Inherited Subclasses inherit it automatically Class-level only; @Auditable class Parent {}Child extends Parent is also @Auditable
@Repeatable Same annotation applied multiple times (Java 8+) Requires a container annotation — see below
Default retention is CLASS, not RUNTIME

If you omit @Retention, your annotation is discarded by the JVM at class-load time — invisible to reflection. This is the most common mistake when writing a first custom annotation: it compiles fine, but method.getAnnotation(MyAnnotation.class) always returns null.

Repeatable annotations

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(Schedules.class)  // points to the container annotation
public @interface Schedule { String day(); String time(); }

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Schedules { Schedule[] value(); }  // holds multiple @Schedule

@Schedule(day = "Monday", time = "9:00")
@Schedule(day = "Friday", time = "16:00")
public void runBackup() { }

// Read with getAnnotationsByType — handles both single and repeated transparently
Schedule[] schedules = method.getAnnotationsByType(Schedule.class);

Reading Annotations at Runtime

This is what every framework does internally — find annotated elements, read their values, act on them. See Reflection API for the underlying mechanism.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Benchmark {
    String description() default "";
    int    iterations() default 1000;
}

public class Calculator {
    @Benchmark(description = "addition", iterations = 10_000)
    public int add(int a, int b) { return a + b; }
}

// A minimal annotation processor — exactly how JUnit and benchmarking tools work
public class BenchmarkRunner {
    public static void run(Class<?> clazz) throws Exception {
        Object instance = clazz.getDeclaredConstructor().newInstance();

        for (Method m : clazz.getDeclaredMethods()) {
            if (!m.isAnnotationPresent(Benchmark.class)) continue;  // skip unannotated

            Benchmark b = m.getAnnotation(Benchmark.class);
            long start = System.nanoTime();
            for (int i = 0; i < b.iterations(); i++) m.invoke(instance, 5, 3);
            long elapsedMs = (System.nanoTime() - start) / 1_000_000;

            System.out.printf("%s: %d iterations in %dms%n", b.description(), b.iterations(), elapsedMs);
        }
    }
}
// Key reflection methods for annotations
clazz.isAnnotationPresent(Entity.class);     // boolean check
clazz.getAnnotation(Entity.class);            // get one specific annotation (or null)
clazz.getAnnotations();                     // all annotations (incl. inherited)
clazz.getDeclaredAnnotations();             // only annotations declared directly here
method.getAnnotationsByType(Schedule.class); // handles @Repeatable transparently

How Frameworks Use Annotations

// Spring — routing, DI, request mapping all driven by annotations
@RestController
@RequestMapping("/api/users")
public class UserController {
    @Autowired private UserService userService;

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) { return userService.findById(id); }
}

// JPA/Hibernate — entity-to-table mapping, relationships
@Entity @Table(name = "users")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "user_name", nullable = false)
    private String username;
}

// Bean Validation (Jakarta) — declarative constraints checked by a validator
public class RegisterRequest {
    @NotNull @Size(min = 3, max = 50)
    private String username;
    @Email
    private String email;
}

// JUnit 5 — test discovery and lifecycle
class CalculatorTest {
    @BeforeEach void setUp() { ... }
    @Test void shouldAdd() { assertEquals(5, 2 + 3); }
    @Test @Disabled("flaky in CI") void shouldDivide() { ... }
}

// Lombok — compile-time code generation, not reflection-based
// @Data alone generates getters, setters, toString, equals, hashCode
@Data @NoArgsConstructor @AllArgsConstructor
public class Product {
    private Long id; private String name; private double price;
}

Senior Topics: Annotation Processing at Compile Time

Everything above reads annotations via reflection at runtime. A fundamentally different mechanism reads them during compilation and generates new source files — this is how Lombok, MapStruct, and Dagger work, and it has zero runtime reflection cost.

// An annotation processor implements Processor (usually via AbstractProcessor)
// and is registered via META-INF/services/javax.annotation.processing.Processor

@SupportedAnnotationTypes("com.app.GenerateBuilder")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class BuilderProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations,
                          RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(GenerateBuilder.class)) {
            TypeElement classElement = (TypeElement) element;
            // Inspect fields via the Element API (compile-time, not reflection)
            String generatedSource = buildBuilderClass(classElement);

            try {
                JavaFileObject file = processingEnv.getFiler()
                    .createSourceFile(classElement.getQualifiedName() + "Builder");
                try (Writer w = file.openWriter()) { w.write(generatedSource); }
            } catch (IOException e) { throw new RuntimeException(e); }
        }
        return true;  // claim these annotations — no other processor handles them
    }
}
// At compile time: javac discovers the processor, runs it BEFORE the final
// compilation pass, the generated .java file is compiled alongside yours.
// Result: zero runtime overhead — the "magic" is fully materialised as code
// you could read, just not code you wrote by hand.
Reflection vs annotation processing — the real tradeoff

Reflection-based (Spring, Hibernate, Jackson): flexible, works with any annotation at runtime, but has invocation overhead and defers errors to runtime. Compile-time processing (Lombok, MapStruct, Dagger, Micronaut): zero runtime cost — generated code is plain Java — and errors surface at compile time, but the processor itself is significantly harder to write. The industry trend (Micronaut, Quarkus, GraalVM native image) is moving toward compile-time processing specifically because reflection is incompatible with ahead-of-time native compilation — there's no running JVM at native-image build time to reflect against.

Common Pitfalls

Forgetting @Retention(RUNTIME)
// ❌ No @Retention → defaults to CLASS → invisible to reflection
public @interface MyAnnotation { }

method.getAnnotation(MyAnnotation.class);  // always null, even if present in source

// ✅ Explicit RUNTIME retention
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation { }
Annotations are not code — don't expect logic from them
// ❌ An annotation does NOTHING by itself — it's just metadata.
// @Transactional only works because Spring's AOP proxy reads it
// and wraps the method call. Without a processor reading it,
// an annotation is inert.
@Transactional
public void transfer() { ... }  // does nothing transactional if called via 'this.transfer()'
                              // from inside the same class — the proxy is bypassed!

Interview Questions

🎓 Junior level

Q: What is the difference between @Retention(SOURCE), CLASS, and RUNTIME?
SOURCE: discarded by the compiler, exists only in the .java file (e.g. @Override — purely a compile-time check). CLASS (the default): present in the .class bytecode but the JVM discards it at class-load time — invisible to reflection. RUNTIME: retained and queryable via reflection at runtime — what virtually all framework annotations (@Autowired, @Entity, @Test) use.

Q: What does @Target control?
Where the annotation is legal to apply — TYPE (class/interface), METHOD, FIELD, PARAMETER, CONSTRUCTOR, etc. Omitting @Target means the annotation can be applied anywhere, which is rarely what you want — always specify it to prevent misuse.

Q: Does an annotation do anything by itself?
No. An annotation is inert metadata. It only has an effect because something reads it — the compiler (@Override), a reflection-based framework at runtime (@Autowired), or an annotation processor at compile time (Lombok's @Data). An annotation with no reader is just a comment the compiler happens to validate the syntax of.

🔥 Senior level

Q: What is the difference between reflection-based and compile-time annotation processing?
Reflection-based frameworks (Spring, Hibernate) read RUNTIME-retained annotations via the java.lang.reflect API while the application is running — flexible but with invocation overhead and runtime-only error detection. Compile-time annotation processors (Lombok, MapStruct, Dagger) implement Processor, hook into javac, and generate new .java source files during compilation — the "magic" is fully materialised as ordinary compiled code, with zero runtime cost and compile-time error detection. The tradeoff: writing a processor is significantly harder than reading annotations via reflection.

Q: Why is annotation processing becoming more important with GraalVM native image?
Reflection requires a running JVM with full classpath knowledge to resolve classes and methods by name at runtime. GraalVM's native-image compiles ahead of time into a standalone binary — there's no JVM at runtime to reflect against, so anything not explicitly configured for reflection (via reflect-config.json) silently fails or must be pre-registered. Frameworks built for native compatibility (Micronaut, Quarkus) deliberately favour compile-time annotation processing over runtime reflection specifically to avoid this friction — Spring's traditional reflection-heavy approach required significant extra tooling (Spring AOT) to support native image at all.

Q: Why might @Transactional silently not work on a self-invoked method?
Spring's @Transactional (and most Spring AOP annotations) work via dynamic proxies — Spring wraps your bean in a proxy that intercepts the method call to start/commit/rollback a transaction before delegating to the real method. If you call the annotated method from within the same class (this.transfer()), you bypass the proxy entirely — the call goes directly to the real object, skipping the interception logic. This is a classic Spring gotcha: the fix is to either call through a separate bean, use AopContext.currentProxy(), or restructure to avoid self-invocation.