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.
Learn Annotations in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
💡 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.
Java ships with a handful of annotations the compiler already understands. You'll use these four constantly:
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.
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.
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") .
A meta-annotation describes how your annotation behaves. These two are the ones you must get right:
@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.
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.
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 #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.
🧩 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.
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.
Practice quiz
Which keyword declares a custom annotation?
- interface
- annotation
- @interface
- @annotation
Answer: @interface. @interface (with the @) declares an annotation. Plain interface declares a normal interface.
To read a custom annotation with reflection at runtime, it MUST be marked:
- @Retention(RetentionPolicy.RUNTIME)
- @Retention(RetentionPolicy.SOURCE)
- @Retention(RetentionPolicy.CLASS)
- @Documented
Answer: @Retention(RetentionPolicy.RUNTIME). Only RUNTIME retention survives to runtime. The default is CLASS, which is discarded before the program runs.
What is the DEFAULT retention policy if you don't specify @Retention?
- SOURCE
- RUNTIME
- There is no default
- CLASS
Answer: CLASS. The default is CLASS: kept in the .class file but not visible to reflection at runtime.
What does @Target control?
- How long the annotation survives
- Where the annotation may be applied (method, field, type, ...)
- Whether subclasses inherit it
- Whether it appears in Javadoc
Answer: Where the annotation may be applied (method, field, type, ...). @Target restricts placement (METHOD, FIELD, TYPE, etc.); @Retention controls how long it survives.
An annotation element declared as String name() default "unnamed"; is:
- Optional — callers may omit it and get "unnamed"
- Mandatory at every use
- Read-only and uneditable
- Illegal syntax
Answer: Optional — callers may omit it and get "unnamed". A default makes the element optional. Without a default, every use must supply a value or it won't compile.
If a method has NO @Test annotation, what does m.getAnnotation(Test.class) return?
- A blank Test instance
- An empty Optional
- null
- It throws an exception
Answer: null. getAnnotation returns null when the annotation is absent. Use isAnnotationPresent for a yes/no check.
What does @Override actually do?
- Changes the method's behaviour at runtime
- Tells the compiler to verify the method really overrides a parent method
- Makes the method run faster
- Marks the method as deprecated
Answer: Tells the compiler to verify the method really overrides a parent method. @Override is a compile-time check: if you mistype the name or parameters, you get an error instead of a silent bug.
Annotation element values must be:
- Any expression including method calls
- Always Strings
- References to local variables
- Compile-time constants (literals, enums, class literals, arrays of those)
Answer: Compile-time constants (literals, enums, class literals, arrays of those). Element values must be constant expressions; you cannot pass a variable or a method call.
@FunctionalInterface causes a compile error unless the interface has:
- At least two abstract methods
- Exactly one abstract method
- No methods
- A default method
Answer: Exactly one abstract method. @FunctionalInterface enforces exactly one abstract method, allowing the interface to be used with a lambda.
The reflection pattern frameworks like Spring and JUnit use to act on annotations is:
- Compile the annotation away
- Ask the user at runtime
- Scan members, read the annotation, then act on it
- Replace the bytecode
Answer: Scan members, read the annotation, then act on it. Loop over methods/fields, read each annotation via reflection, and react — the same scan-read-act loop you build here.