Java Repeatable Annotations and Container Classes
Part 6 of 9 in Functional Java Unleashed
Java annotations are a form of metadata attached to code elements — classes, methods, fields, parameters, and so on. You’ve used them without thinking about the mechanics: @Override, @Deprecated, @SuppressWarnings. These are annotations defined by the JDK that convey simple hints to the compiler.
But annotations carry more than hints. In frameworks like Spring or JPA they hold structured configuration — things like transaction boundaries, cache policies, or mapping rules — embedded directly in your code. And starting with Java 8, there is a feature designed specifically for the “multiple pieces of metadata on one element” pattern: repeatable annotations.
The mechanism behind them is deceptively simple but produces a surprising gotcha when you try to read them via reflection. This post walks through how the compiler stores repeatable annotations inside a container class, why getAnnotation() silently returns null, and what API to use instead.
The code
The program defines three custom annotations — a traditional non-repeatable one (@Author), a repeatable one (@Tag) with its container (@TagContainer) — and demonstrates reading them both on a class and a method via reflection.
import java.lang.annotation.*;
import java.lang.reflect.*;
@interface Author {
String name();
String role();
}
@Repeatable(TagContainer.class)
@interface Tag {
String value();
}
@interface TagContainer {
Tag[] value();
}
class MyClass {
@Author(name = "Jane Doe", role = "Creator")
static class MyInner {}
@Tag("public")
@Tag("api")
@Tag("stable")
public void doSomething() {}
}
class ClassWithSingleTag {
@Tag("singleton")
public void doOneThing() {}
}
public class AnnotationDemo {
public static void main(String[] args) throws Exception {
// Regular annotation — works fine
Class<?> innerClass = MyClass.MyInner.class;
Author author = innerClass.getAnnotation(Author.class);
System.out.println("Regular annotation (Author):");
System.out.printf(" name=%s, role=%s%n", author.name(), author.role());
// Repeatable — this looks wrong but is intentional
Method method = MyClass.class.getMethod("doSomething");
Tag directTag = method.getAnnotation(Tag.class);
System.out.printf("\ngetAnnotation(Tag.class): %s%n", directTag);
// The correct API for repeatable annotations
Tag[] tags = method.getAnnotationsByType(Tag.class);
System.out.println("\nUsing getAnnotationsByType():");
for (Tag t : tags) {
System.out.printf(" tag=%s%n", t.value());
}
// Also works: read via the container class explicitly
TagContainer container = method.getAnnotation(TagContainer.class);
System.out.println("\nVia container class (TagContainer):");
for (Tag t : container.value()) {
System.out.printf(" value=%s%n", t.value());
}
// Edge case: exactly one instance
Method singleMethod = ClassWithSingleTag.class.getMethod("doOneThing");
Tag singleDirect = singleMethod.getAnnotation(Tag.class);
System.out.println("\n── Edge case: single @Tag instance ─────────────────");
System.out.printf("getAnnotation(Tag.class) on single instance: %s%n", singleDirect);
}
}
Three things to notice before running. First, @Repeatable is a meta-annotation — it tells the compiler what container type to use when multiple instances of @Tag appear on one element. Second, the container (TagContainer) must have a single array-valued attribute named value() with element type matching the annotated type. Third, and most important: the call to method.getAnnotation(Tag.class) looks like it should return one of the @Tag instances — but that expectation turns out to be wrong.
Running it
The first block shows that a regular annotation (@Author) works exactly as you’d expect: getAnnotation() finds and returns it with all attribute values intact.
Then comes the surprise. The method has three @Tag annotations, but getAnnotation(Tag.class) returns null. This is not a bug — it’s how the Java reflection API was designed for repeatable types. When you mark an annotation as @Repeatable, the compiler stops storing instances of that type directly on the annotated element. Instead, it wraps all instances inside the container (@TagContainer). At runtime there is no standalone @Tag present on the method — only @TagContainer. The getAnnotation() call looks for a direct @Tag annotation and finds nothing.
The correct API for repeatable annotations is getAnnotationsByType(), which the JDK 8 reflection library added specifically for this pattern. It knows to unwrap any container automatically and returns all wrapped instances as an array. The output shows exactly the three values — public, api, stable — in declaration order.
Reading via the container class explicitly works too: call getAnnotation(TagContainer.class) and iterate over .value(). This is what happens internally inside getAnnotationsByType().
The final block demonstrates an edge case that trips people up: if a method has exactly one instance of a repeatable annotation, getAnnotation() does return it. The compiler only applies the container wrapper when there are multiple instances — with one instance, it stores it directly as a regular annotation for compatibility. This means your code can break at runtime depending on how many times an annotation happens to be applied.
Takeaway
A repeatable annotation is syntactic sugar: the compiler rewrites @Tag("a") and @Tag("b") into @TagContainer({ @Tag("a"), @Tag("b") }). When reading annotations via reflection, always use getAnnotationsByType() for repeatable types — relying on getAnnotation() will give you null when multiple instances exist.