JAVA / PLATFORM, BUILDS AND TESTING
Writing a custom annotation with retention rules
Declare your own annotation type with typed members and defaults, and pick SOURCE, CLASS or RUNTIME retention so the right tool can still see it.
What you will learn
- Declare annotation types with @interface and method-shaped members that carry defaults
- Choose SOURCE, CLASS or RUNTIME deliberately; the silent default is CLASS, not RUNTIME
- Read RUNTIME uses with getAnnotation, getDeclaredAnnotations and getAnnotationsByType
- Add @Target so a misplaced annotation is a compile error instead of a silent no-op
Understanding Writing a custom annotation with retention rules
An annotation type is declared with @interface, and its members look like no-argument methods: int times() default 3; declares a member named times whose value is an int. The member types are restricted to primitives, String, Class, enums, other annotation types, and single-dimension arrays of those, because every value has to be storable as a constant in the class file. Defaults are stored once on the annotation type itself, not copied into each use, which is why r.times() returns 3 for a bare @Retry even though nothing was written at the use site.
Retention answers one question: how far does a use of the annotation travel? javac holds every annotation while it compiles, so SOURCE-retained ones can drive annotation processors and compiler checks and are then thrown away, leaving no trace in the class file at all. CLASS-retained uses are written into the class file as a RuntimeInvisibleAnnotations attribute, which bytecode readers and static analysers can parse but java.lang.reflect deliberately ignores. Only RUNTIME uses land in RuntimeVisibleAnnotations, and only those are handed back by getAnnotation. Omitting @Retention gives you CLASS, so a forgotten @Retention produces an annotation that is genuinely in the class file yet invisible to the reflection code that was supposed to act on it.
Retention is independent of @Target: target says where the annotation may be written, retention says how long the use survives, and you normally set both. RUNTIME retention also creates a real runtime dependency, since reflection has to load the annotation type to build the proxy object it returns; if that class is missing from the classpath, reflection quietly omits the annotation instead of failing, so a missing jar looks exactly like a missing annotation. That is why compile-only markers such as nullability annotations are usually CLASS-retained: the bytecode tooling still reads them, but nothing has to be shipped at runtime.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Retry {
int times() default 3;
String label() default "";
}
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
@interface Audited {
}
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.METHOD)
@interface Draft {
}
public class RetentionDemo {
@Retry(times = 5, label = "network")
@Audited
@Draft
static void fetch() {
}
@Retry
static void ping() {
}
public static void main(String[] args) throws Exception {
for (Class<?> a : new Class<?>[] { Retry.class, Audited.class, Draft.class }) {
Retention r = a.getAnnotation(Retention.class);
System.out.println(a.getSimpleName() + " retention = " + r.value());
}
Method fetch = RetentionDemo.class.getDeclaredMethod("fetch");
System.out.println("fetch has 3 annotations in source, reflection sees "
+ fetch.getDeclaredAnnotations().length);
Retry r = fetch.getAnnotation(Retry.class);
System.out.println("fetch times=" + r.times() + " label='" + r.label() + "'");
System.out.println("fetch audited? " + fetch.isAnnotationPresent(Audited.class));
Retry p = RetentionDemo.class.getDeclaredMethod("ping").getAnnotation(Retry.class);
System.out.println("ping times=" + p.times() + " label='" + p.label() + "'");
}
}Retention decides how far each use of your annotation travels — javac only, into the class file, or all the way to reflection — and the unwritten default is CLASS.
Worked examples
Array members, the value shorthand and where defaults live
Shows the single-member shorthand for value() and proves that defaults are stored on the annotation type rather than at each use site.
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Arrays;
@Retention(RetentionPolicy.RUNTIME)
@interface Roles {
String[] value();
boolean strict() default false;
}
@Roles("admin")
class AdminPanel {
}
@Roles(value = { "admin", "auditor" }, strict = true)
class Ledger {
}
public class ArrayMembers {
public static void main(String[] args) throws Exception {
for (Class<?> c : new Class<?>[] { AdminPanel.class, Ledger.class }) {
Roles r = c.getAnnotation(Roles.class);
System.out.println(c.getSimpleName() + " " + Arrays.toString(r.value())
+ " strict=" + r.strict());
}
System.out.println("default of strict: "
+ Roles.class.getDeclaredMethod("strict").getDefaultValue());
System.out.println("default of value: "
+ Roles.class.getDeclaredMethod("value").getDefaultValue());
}
}Example explained
Line 1String[] value(); makes value the shorthand member, so @Roles("admin") needs neither braces nor a name and yields a one-element array.
Line 2Once a second member is supplied, the shorthand is gone and value has to be named explicitly, as on Ledger.
Line 3getDefaultValue() reads the AnnotationDefault attribute of the annotation type, which is why strict=false appears although neither class wrote it.
Line 4value has no default, so its getDefaultValue() is null and every use is forced by the compiler to supply it.
@Inherited only helps if the annotation is also RUNTIME
Demonstrates that @Inherited changes the lookup on subclasses for getAnnotation but never for getDeclaredAnnotations, and only matters when reflection can see the annotation at all.
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@interface Component {
String name();
}
@Retention(RetentionPolicy.RUNTIME)
@interface Scoped {
String value();
}
@Component(name = "base")
@Scoped("singleton")
class Base {
}
class Child extends Base {
}
public class InheritedDemo {
public static void main(String[] args) {
System.out.println("Child sees @Component: "
+ (Child.class.getAnnotation(Component.class) != null));
System.out.println("Child sees @Scoped: "
+ (Child.class.getAnnotation(Scoped.class) != null));
System.out.println("declared on Child: " + Child.class.getDeclaredAnnotations().length);
System.out.println("getAnnotations on Child: " + Child.class.getAnnotations().length);
System.out.println("inherited name: " + Child.class.getAnnotation(Component.class).name());
}
}Example explained
Line 1@Inherited makes Child.class.getAnnotation(Component.class) continue up the superclass chain to Base.
Line 2@Scoped is RUNTIME but not @Inherited, so the identical lookup on Child returns null even though Base carries it.
Line 3getDeclaredAnnotations() reports 0 because 'declared' means physically written on Child; only getAnnotations and getAnnotation honour @Inherited.
Line 4If Component were CLASS-retained, @Inherited would be inert at runtime, because reflection would never see the annotation on Base either.
@Repeatable and its container's retention
Shows how repeated annotations are folded into a container annotation and why the container must be retained at least as long as the repeatable one.
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Method;
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Schedules.class)
@interface Schedule {
String cron();
}
@Retention(RetentionPolicy.RUNTIME)
@interface Schedules {
Schedule[] value();
}
public class RepeatableDemo {
@Schedule(cron = "0 0 * * *")
@Schedule(cron = "0 12 * * *")
static void report() {
}
public static void main(String[] args) throws Exception {
Method m = RepeatableDemo.class.getDeclaredMethod("report");
Schedule[] all = m.getAnnotationsByType(Schedule.class);
System.out.println("byType count: " + all.length);
for (Schedule s : all) {
System.out.println("cron " + s.cron());
}
System.out.println("getAnnotation(Schedule.class): " + m.getAnnotation(Schedule.class));
System.out.println("container present: " + m.isAnnotationPresent(Schedules.class));
}
}Example explained
Line 1@Repeatable(Schedules.class) permits two @Schedule on one method; javac rewrites them into a single @Schedules holding both.
Line 2getAnnotationsByType unwraps the container and returns the elements in source order.
Line 3getAnnotation(Schedule.class) is null because what the class file actually stores is @Schedules, which is why isAnnotationPresent(Schedules.class) is true.
Line 4The container must be retained at least as long as the repeatable type; a SOURCE container for a RUNTIME @Schedule is rejected at compile time.
Important notes
CLASS retention does not erase anything: javap -v lists the annotation under RuntimeInvisibleAnnotations and bytecode libraries read it fine; it is java.lang.reflect specifically that ignores it.
Adding a new member without a default to an existing RUNTIME annotation breaks classes compiled against the old version — reading that member throws IncompleteAnnotationException at runtime, so extend annotations with defaults.
Common mistakes
Leaving @Retention off the annotation type: it defaults to CLASS, so the annotation compiles, appears in the class file, and yet isAnnotationPresent returns false and the framework silently skips the element with no error anywhere to point at.
Writing members like String name() default null; or Date created(); — annotation members cannot default to null and cannot use arbitrary types, so javac rejects them with 'attribute value must be constant' and 'invalid type for annotation member'.
Giving a RUNTIME annotation @Target(ElementType.LOCAL_VARIABLE) and expecting to read it later: there is no reflection API for local variables, so no retention setting makes that annotation reachable at runtime.
Try it yourself
Change, predict, then run
Write @Trace with int level() default 1, apply it to two methods with different levels, and print each method's level using getDeclaredAnnotations(). Then change its retention to CLASS, rerun, and confirm the printed annotation count drops to zero.
Open the Java workspaceCheck your understanding
A custom @Cacheable annotation compiles cleanly and shows up when you run javap -v on the compiled class, but a reflection-based framework never applies caching. What is the most likely cause?
- @Target is missing, so the annotation cannot legally be applied to methods
- Its members have no default values, so reflection cannot construct the annotation object
- It has CLASS retention, so the use is stored as RuntimeInvisibleAnnotations and reflection skips it
- The annotation is not marked @Inherited, so getAnnotation cannot find it
Show answer
CLASS is what you get when @Retention is omitted, and it writes the use into the class file as an invisible attribute — exactly the combination described, where javap shows it but getAnnotation returns null. Omitting @Target does the opposite of restricting placement: with no @Target the annotation is permitted in every declaration context, so it would not block use on a method.