Java Custom Annotations
Define your own annotation types in Java, configure their retention and targets, and read them at runtime.
Java Custom Annotations
A custom annotation is one you declare yourself. The syntax looks like an interface; the rules are stricter. Once declared, your annotation becomes a real type that you can attach to code, look up via reflection, and process at compile time. This chapter is the practical guide to writing them: the @interface keyword, what elements they may have, and how a processor reads them back at runtime.
The @interface declaration
An annotation type is declared with the @interface keyword:
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Audited {
String value(); // required element
String level() default "INFO"; // element with a default
String[] tags() default {}; // array element with default
}This declares a new annotation type called Audited whose elements look like methods on an interface but behave as named values at use sites. Each "method" is an element.
Use it like this:
@Audited("UserService.login") // value omitted name → "value" element
public User login(String user, String password) { ... }
@Audited(value = "Service.save", level = "WARN", tags = {"db", "write"})
public void save(Entity e) { ... }The value shortcut (@Audited("...") instead of @Audited(value = "...")) is available only when the element is literally named value, which is why so many annotations use exactly that name for their primary parameter.
What elements are allowed
The body of an @interface is a closed set of element declarations. Each element's return type must be one of:
- A primitive (
int,long,double,boolean, ...). String.Classor a parameterisedClass<?>.- An enum type.
- Another annotation type.
- An array of any of the above.
Default values are written with default. The default must be a compile-time constant of the right type:
@interface RetryPolicy {
int attempts() default 3;
long delayMs() default 100;
Class<? extends Exception>[] on() default {Exception.class};
Level level() default Level.WARN;
enum Level { DEBUG, INFO, WARN, ERROR }
}What you can't declare in an annotation:
- Methods that take parameters (the
()is required but always empty). - Generic elements (
<T> T value();is illegal). throwsclauses.- Inheritance from another interface (annotations implicitly extend
java.lang.annotation.Annotation). - Constructors.
You can nest types inside an annotation declaration — the Level enum above lives inside @RetryPolicy. That's a useful idiom: it keeps related options scoped to the annotation that uses them.
Required vs. optional elements
An element without default is required at use sites. The compiler fails if you forget it:
@interface Issue { String id(); } // required
@Issue // compile error: missing 'id'
public void fixed() { }
@Issue(id = "JIRA-123") // OK
public void fixed() { }A small piece of style: if there's a single obvious value, name the element value and make it required. If there are several knobs, name them and give sensible defaults so the common call stays short.
Marker annotations
An annotation with no elements at all is a marker:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@interface ThreadSafe { }Marker annotations don't carry data; their presence or absence is the entire signal. Reflection asks "does this class have @ThreadSafe?" with getAnnotation(ThreadSafe.class) != null or isAnnotationPresent(ThreadSafe.class).
Reading annotations at runtime
For a RUNTIME annotation, reflection exposes three families of methods on Class, Method, Field, Constructor, Parameter:
isAnnotationPresent(Class)— quick yes/no.getAnnotation(Class)— returns the annotation instance, ornull.getAnnotations()— returns all annotations on the element (declared + inherited via@Inherited).getDeclaredAnnotations()— only those declared directly on the element, ignoring@Inherited.getAnnotationsByType(Class)— handles the@Repeatablecase correctly.
Reading is the same shape regardless of which target type you're working with:
Method m = ...;
if (m.isAnnotationPresent(Audited.class)) {
Audited a = m.getAnnotation(Audited.class);
log(a.value(), a.level(), a.tags());
}The returned Audited is a JVM-generated proxy — the element methods (value(), level(), tags()) are real method calls on it.
Annotation equality, identity, and toString
Annotation values implement equals, hashCode, and toString as defined by java.lang.annotation.Annotation:
- Two annotation instances are equal when they're of the same type and every element compares equal (with array deep-equality).
hashCodeis derived from the element values in a defined way.toStringproduces a stable, source-like rendering — useful for logging.
Reflection sometimes returns the same proxy for repeated lookups on the same element, and sometimes returns a fresh one. Use equals, never ==, when comparing annotation instances.
A worked example: define, attach, and reflect
The program declares two annotations (@Audited and @Retry), uses them on a class, and walks the methods with reflection — running each method either inside an auditing wrapper or with a retry loop. The annotations are pure metadata; the behaviour lives in the executor.
What to take from the run:
greetcarried only@Audited, so the executor printed an enter/exit pair around the method but did not retry. The same executor handledsaveby spotting@Retryon top of@Auditedand looping when the first invocation threw. The annotations themselves did nothing — theinvokehelper supplied the behaviour.unannotatedran through the same loop because the executor is uniform.isAnnotationPresentreturnedfalsefor both annotations, so the helper neither logged nor retried; the method just executed once. That's the pattern for processors: examine annotations, default sensibly when they're absent, never special-case for "this is the annotated path."- Each element accessor (
a.value(),r.attempts(),r.when()) returned the value written in source.Retry.when()came back as the enum constantALWAYSbecause the call site used the default. Defaults are baked into the annotation proxy by the compiler; the caller can't tell whether a value was explicit or defaulted. - The
toStringofAuditedprinted a source-like form (@...Audited(value=\"Service.save\", level=\"WARN\")). That's a property of every annotation proxy — useful for logging and forassertEqualsin tests. - The two annotations are entirely independent at the source level: one method carries both at once and reflection happily returned both. There is no inheritance hierarchy between annotation types; combining behaviours is achieved by stacking annotations on the same element, not by extending one annotation from another.
Where this stops working
A few common surprises:
- Source retention can't be reflected. If you forget
@Retention(RUNTIME), reflection silently returnsnull. The default isCLASS, notRUNTIME. - Targets must match. If
@Target(METHOD)and you put the annotation on a class, the compiler refuses. - Element defaults must be compile-time constants. You can't default to
new ArrayList<>(); you can default to{}for an array, an enum constant, aClassliteral, or a primitive literal. - Annotations can't reference themselves cyclically. An element of type
MyAnninside@interface MyAnnis rejected.
The next chapter shows the compile-time side of annotation processing — generating new source files in response to your custom annotations, instead of (or in addition to) reading them at runtime.
Practice
You declare `@Cached { int ttlSeconds(); }` and put it on a method. At runtime `m.getAnnotation(Cached.class)` returns `null` even though the source clearly has `@Cached(ttlSeconds = 60)`. What's the most likely cause?