A Java annotation is metadata you attach to code with @Name. You declare a custom one with @interface, then tell the compiler where it may sit (@Target) and how long it lives (@Retention). SOURCE is dropped at compile time, CLASS is stored in the .class file but not visible at runtime, and RUNTIME can be read with reflection. Frameworks such as JUnit (@Test), Spring (@Autowired), and Jackson (@JsonProperty) are just custom annotations plus a processor. To write your own, define the annotation, mark the code, then either scan it at runtime with isAnnotationPresent or generate code at compile time with an annotation processor.
Key Takeaways
- Annotations do not change how a method runs by themselves. Something else, a compiler plugin, a framework, or your own reflection code, has to look at them and act.
- Always set @Retention and @Target. The default retention is CLASS, which is why a runtime scanner often sees nothing even though the annotation is on the source.
- RUNTIME retention plus reflection is how JUnit finds @Test methods. SOURCE retention plus an annotation processor is how Lombok and MapStruct generate code before your app starts.
- Annotation members look like methods. They can return primitives, String, Class, enums, annotations, or arrays of those, and they can have defaults. They cannot take parameters or throw checked exceptions.
- getDeclaredMethods sees private methods on that class. getMethods sees public methods including inherited ones. Pick the API that matches where you put the annotation.
- Prefer compile-time processing when you can. Runtime reflection is flexible, but it is slower, easier to get wrong with proxies, and it pushes failures from build time to production.
You put @Test on a method and JUnit runs it. You put @Autowired on a field and Spring fills it in. You put @JsonProperty on a getter and Jackson names the JSON field. None of those tags is magic. Each one is a small piece of metadata, plus some other code that knows how to read it.
That other code is the interesting part. A Java annotation does nothing by itself. The compiler, a framework, or a few lines of reflection have to look at the tag and decide what to do. Once you see that split, writing your own annotations stops feeling like advanced Java and starts feeling like a labeling system.
This post rebuilds that system from scratch. We will look at the annotations you already use, the meta-annotations that control them, a custom @MyTest runner you can run locally, then the compile-time path that tools like Lombok use. The original mini test runner on GitHub is still the working example. If you want the runtime that actually stores this metadata, the how the JVM works guide sits underneath everything here.
Glossary terms in this post
- Java Annotation A Java annotation is metadata attached to source code with an @Name tag. You declare a custom annotation with @interface, then control...
- Annotation Processor An annotation processor is a plugin that javac runs while it compiles your code. It inspects Java annotations on source elements and...
- Java Virtual Machine The Java Virtual Machine, or JVM, is the runtime engine that executes Java bytecode. When you compile Java source with javac you...
- Java Bytecode Bytecode is the compact, platform-independent instruction set that the JVM executes. The javac compiler does not turn Java source into native machine...
- Class Loader A class loader is the part of the JVM that finds .class files and brings their bytecode into memory. It works in...
What a Java Annotation Actually Is
An annotation is data about your code, not code that runs. Oracle’s tutorial puts it plainly: annotations have no direct effect on the operation of the code they annotate. @Override does not override anything. It tells javac “please fail the build if this method does not override a parent method.” @Deprecated does not stop callers. It tells the compiler and your IDE to warn them.
That is a different idea from a method call or an if. You are attaching a label. Some other program consumes the label later.
Java got annotations in JDK 5. Before that, frameworks stuffed the same information into XML files or marker interfaces (empty interfaces like Serializable). Annotations won because the metadata lives next to the thing it describes. When you rename a field, the @Column on it moves with it. An XML mapping in another file does not.
You declare a custom annotation with @interface. That is not a coincidence. An annotation type is a special kind of interface. The compiler generates a real interface for it, and at runtime you get a proxy that implements that interface so timeout() can return the value you wrote in source.
Annotations You Already Use
If you write tests, you have used @Test. JUnit 5’s @Test lives in org.junit.jupiter.api. JUnit 4’s lives in org.junit. Same idea: mark methods, let a runner find them. You do not call firstTest() from main. The runner does.
If you map JSON, Jackson’s @JsonProperty and Gson’s @Expose tell the library which fields to include and what to name them. Hibernate and JPA use @Entity and @Column the same way for tables. Spring Boot apps are covered in annotations: @SpringBootApplication, @RestController, @GetMapping, @Autowired.
The JDK itself ships a small set you should know:
@Overrideis a compile-time check. Retention is SOURCE.@Deprecatedmarks APIs that should not be used. Since Java 9 it can carrysinceandforRemoval.@SuppressWarningssilences specific compiler warnings. Also SOURCE.@FunctionalInterfacedocuments that an interface has exactly one abstract method, and the compiler enforces it.@SafeVarargsis a promise about generic varargs.
Those last few are processed by javac. You never write a loop that looks for @Override. Custom annotations you invent for your own framework almost always need you to write that loop, or to write a processor that runs at compile time.
Meta-Annotations: Retention and Target
When you define an annotation, you annotate the annotation. Those tags are called meta-annotations. Two of them decide whether your custom type is usable.
@Retention answers “how long does this live?”
@Target answers “where may I put this?”
flowchart LR
A["fa:fa-file-code <b>Source</b><br/>@MyTest on a method"] --> B["fa:fa-cog <b>javac</b>"]
B -->|SOURCE| X["fa:fa-trash <b>Dropped</b><br/>not in .class"]
B -->|CLASS default| C["fa:fa-file <b>.class file</b><br/>annotation stored"]
B -->|RUNTIME| C
C -->|CLASS| Y["fa:fa-eye-slash <b>JVM may drop it</b><br/>reflection sees nothing"]
C -->|RUNTIME| D["fa:fa-search <b>Reflection</b><br/>isAnnotationPresent"]
classDef src fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0f172a
classDef compile fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#0f172a
classDef cls fill:#e0f2fe,stroke:#0891b2,stroke-width:2px,color:#0f172a
classDef run fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a
classDef drop fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#0f172a
class A src
class B compile
class C cls
class D run
class X,Y drop
Popular reads
View AllThe three retention policies are:
| Policy | Still in source | In the .class file |
Visible with reflection |
|---|---|---|---|
| SOURCE | yes | no | no |
| CLASS | yes | yes | no |
| RUNTIME | yes | yes | yes |
CLASS is the default if you forget @Retention. That default is the number one reason a custom annotation “does not work.” You put it on a method, you print getAnnotation, you get null. The compiler did its job. The JVM was never asked to keep the metadata around.
Use SOURCE when only the compiler or an annotation processor cares, for example Lombok-style code generation. Use RUNTIME when JUnit-style discovery, Spring-style injection, or your own scanner will read the annotation while the program runs. CLASS is rare in application code. Some bytecode tools read it from the class file without loading the class, but if you are using java.lang.reflect, you want RUNTIME.
@Target takes one or more ElementType values. The ones you will actually use:
TYPE: class, interface, enum, record, annotation typeMETHOD,FIELD,CONSTRUCTOR,PARAMETERTYPE_USEandTYPE_PARAMETER: Java 8 type annotations, such asList<@NonNull String>ANNOTATION_TYPE: you can only put this on another annotation (meta-annotation)RECORD_COMPONENT: Java 16 records
If you omit @Target, the annotation is allowed on most declarations. Be explicit anyway. A test marker that accidentally compiles on a field is a footgun.
A few other meta-annotations show up often:
@Documentedincludes the annotation in Javadoc.@Inheritedcopies a class-level annotation from a superclass to a subclass. It does not copy method annotations, and it does not apply to interfaces.@Repeatable(Java 8) lets you write the same annotation twice on one element. You also define a containing annotation that holds an array of them.
How to Create a Custom Annotation
Start with the smallest useful example: a marker that means “this method is a test.” That is how JUnit began, and it is enough to teach the whole loop.
1
2
3
4
5
6
7
8
9
10
11
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyTest {
long timeout() default 0L;
String reason() default "";
}
Members look like methods. They cannot take parameters or throw exceptions. Allowed return types are primitives, String, Class, enums, other annotations, and arrays of those. A default cannot be null.
If you have a single member named value, callers can skip the name:
1
2
3
4
5
6
public @interface Role {
String value();
}
@Role("admin") // short form
@Role(value = "admin") // same thing
That is why so many library annotations are written @Qualifier("mainDataSource") instead of @Qualifier(name = "mainDataSource").
You can also make a marker with no members at all, which is what the first version of @MyTest was. Markers are fine when presence is the only signal you need.
A Tiny Test Runner with Reflection
Here is the original idea, cleaned up. You mark tests with @MyTest. A runner scans the class and invokes those methods. Helper methods without the annotation are ignored. Full source is in the JavaCustomAnnotations repo.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SampleTests {
@MyTest
public void firstTest() {
System.out.println("Running first test");
}
@MyTest(timeout = 1000, reason = "checks the happy path")
public void secondTest() {
System.out.println("Running 2nd test");
}
public void thirdTest() {
System.out.println("This is not a test");
}
private void helperMethod() {
System.out.println("I am a helper");
}
}
The runner uses reflection. It asks the class loader’s Class object for methods, then checks each one:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class TestRunner {
public void runTests(Object test) throws Exception {
Method[] allMethods = test.getClass().getDeclaredMethods();
for (Method method : allMethods) {
executeMethod(test, method);
}
}
private void executeMethod(Object test, Method method)
throws InvocationTargetException, IllegalAccessException {
if (!method.isAnnotationPresent(MyTest.class)) {
return;
}
MyTest spec = method.getAnnotation(MyTest.class);
method.setAccessible(true);
long started = System.nanoTime();
method.invoke(test);
long tookMs = (System.nanoTime() - started) / 1_000_000;
if (spec.timeout() > 0 && tookMs > spec.timeout()) {
throw new AssertionError(
method.getName() + " took " + tookMs + " ms, limit is " + spec.timeout());
}
}
}
getDeclaredMethods() returns methods declared on that class, including private ones, and does not walk the superclass. getMethods() returns public methods, including inherited ones. JUnit-style runners usually want declared methods on the test class, so getDeclaredMethods is the right call. setAccessible(true) is only needed if you allow package-private or private tests.
Wire it from main:
1
2
3
4
5
public class Main {
public static void main(String[] args) throws Exception {
new TestRunner().runTests(new SampleTests());
}
}
Output is the two annotated methods. thirdTest and helperMethod never run, because they have no @MyTest.
flowchart TD
M["fa:fa-play <b>main</b>"] --> R["fa:fa-cogs <b>TestRunner.runTests</b>"]
R --> G["fa:fa-list <b>getDeclaredMethods</b>"]
G --> Q{"isAnnotationPresent<br/>MyTest?"}
Q -->|yes| I["fa:fa-bolt <b>method.invoke</b>"]
Q -->|no| S["fa:fa-forward skip"]
I --> T["fa:fa-clock optional timeout check"]
classDef start fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0f172a
classDef work fill:#e0f2fe,stroke:#0891b2,stroke-width:2px,color:#0f172a
classDef yes fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a
classDef no fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#0f172a
class M start
class R,G work
class I,T yes
class S no
That is the entire JUnit discovery model, minus reporting, assertions, and lifecycle callbacks. JUnit 5’s Jupiter engine still does a version of this scan, then builds a test plan from the methods it found.
One reflection detail that bites people: method.invoke wraps failures in InvocationTargetException. The real assertion error or runtime exception is getCause(). Unwrap it if you are building a runner you actually want to use.
Runtime vs Compile Time
You now have two ways to consume an annotation. Pick based on when the work should happen.
flowchart TD
Q{"When must the<br/>annotation be read?"}
Q -->|While the app runs| RT["fa:fa-search <b>RUNTIME + reflection</b><br/>JUnit, Spring, Jackson"]
Q -->|While javac runs| CT["fa:fa-magic <b>SOURCE + processor</b><br/>Lombok, MapStruct, Dagger, Room"]
RT --> RTN["Flexible, no extra build setup<br/>Cost: slower, errors at runtime"]
CT --> CTN["Zero runtime cost<br/>Cost: processor module, rebuild"]
classDef q fill:#dbeafe,stroke:#1d4ed8,stroke-width:2px,color:#0f172a
classDef rt fill:#c8e6c9,stroke:#16a34a,stroke-width:2px,color:#0f172a
classDef ct fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#0f172a
classDef note fill:#e0f2fe,stroke:#0891b2,stroke-width:2px,color:#0f172a
class Q q
class RT rt
class CT ct
class RTN,CTN note
Runtime is what we just built. The bytecode still contains the annotation, the JVM keeps it, and your code asks for it. Spring’s @Transactional, Jackson’s @JsonIgnore, and Bean Validation’s @NotNull all work this way (sometimes with a bytecode enhancer on top).
Compile time uses the annotation processing API in javax.annotation.processing (still that package name inside the JDK). You extend AbstractProcessor, list the types you support, and in process you can emit new source files. javac compiles those in a later round. That is how MapStruct writes mapper implementations and how Dagger writes the dependency graph. Android’s Room does the same during the Android build process. Kotlin projects go through kapt or the faster KSP.
A processor skeleton looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import java.util.Set;
@SupportedAnnotationTypes("com.example.MyTest")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class MyTestProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
roundEnv.getElementsAnnotatedWith(MyTest.class)
.forEach(element -> {
// validate, or write a new .java file
});
return true;
}
}
You register the processor in META-INF/services/javax.annotation.processing.Processor, or with Google’s AutoService. For SOURCE retention, the annotation never reaches the running app. That is a feature: generated code has already done the work.
If you are choosing for a new library, prefer compile time when the annotation would otherwise force every call site to pay for reflection, or when a mistake should fail the build. Prefer runtime when behavior depends on objects that only exist after startup, such as “inject this field from the current Spring context.”
How Popular Libraries Use the Same Trick
Once you have written @MyTest, the big frameworks look less mysterious.
JUnit discovers tests by annotation, then runs before/after callbacks the same way. A JUnit Rule is another annotation (@Rule) pointing at an object that wraps each test.
Spring reads @Component, @Service, and @Autowired to build the application context. Around @Transactional it often creates a proxy so that begin/commit/rollback wrap the real method. If you later inspect the proxy with raw reflection, you may not see the annotation that was on the target class. Spring’s AnnotationUtils and AopUtils exist because of that gap.
Jackson and Gson walk fields and methods, skip anything without the right annotation (or honor @JsonIgnore), and name properties from @JsonProperty.
Jakarta Bean Validation (@NotNull, @Size) is read by a validator at runtime, often on a REST controller argument.
Lombok is the compile-time extreme: @Getter is gone from the bytecode after the processor writes an actual getName() method. Your running program never sees @Getter.
You can mix both styles in one annotation type, but it is usually cleaner to pick one retention and stick to it.
Mistakes That Waste an Afternoon
Forgetting RUNTIME. If your scanner uses reflection, @Retention(RetentionPolicy.RUNTIME) is not optional. CLASS will compile and still return null.
Scanning the wrong methods. Private @MyTest methods are invisible to getMethods(). Inherited public tests are invisible to getDeclaredMethods(). Match the API to your rule.
Looking at a JDK proxy. Proxy.newProxyInstance can hide annotations on the target. Spring MVC controllers with class-level annotations sometimes need AnnotationUtils.findAnnotation instead of clazz.getAnnotation.
Expecting @Inherited on methods. It only copies type-level annotations down a class hierarchy. A @MyTest on a superclass method is already on that method object when you scan the superclass. It is not copied onto an override unless you put it on the override too.
Illegal member types. You cannot put a List<String> on an annotation. Use String[]. You cannot default a String member to null. Use "" or make the member required.
Doing heavy work in a runtime scanner on a hot path. Reflection is fine at startup (Spring does a lot of it once). It is a poor choice inside a tight request loop. Cache the Method list, or generate code at compile time.
Security and setAccessible. Opening private methods is what a test runner needs. The same trick in production code can break modules (Java 16+ stronger encapsulation) and can surprise callers who thought a method was private. Keep it in frameworks, not in random business logic.
Wrapping Up
A custom Java annotation is a labeled interface plus a consumer. You write @interface, pin it down with @Target and @Retention, put it on the code you care about, then either scan it at runtime with reflection or let javac run a processor. That is the same pattern behind JUnit, Spring, Jackson, Hibernate, and Lombok.
The @MyTest runner is enough to prove the runtime path: marker on a method, isAnnotationPresent, invoke. Add members when you need data, not just a flag. Switch to SOURCE retention and an annotation processor when you want the work done before the process even starts.
Keep the retention policy honest, scan the methods you actually annotated, and remember that the annotation never runs by itself. The interesting code is always the reader.
Related posts:
- How the JVM Works - Where class metadata and the Class object live, which reflection reads
- How Java Debugging Works - Another look at JVM metadata, this time for breakpoints
- Java 25 LTS Features - What a modern JDK adds on top of the language these annotations use
- JUnit Rules - Reusable test lifecycle, driven by the
@Ruleannotation - Android Build Process - Where Dagger, Room, kapt, and KSP run processors during a build
- Proxy Design Pattern - How Spring wraps
@Transactionalmethods without changing your class
Further reading: Oracle’s Annotations tutorial and declaring an annotation type, the RetentionPolicy API docs, Baeldung on custom annotations and annotation processing, and Vogella’s annotations and reflection tutorial.