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.