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 |
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-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
// ❌ 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 { }
// ❌ 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
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.
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.