Annotations in Java: Built-in, Custom, and Retention Policies

Executive Summary: Java annotations are types declared with @interface whose elements hold compile-time constants. This post covers built-ins like @Override and @FunctionalInterface, meta-annotations such as @Retention and @Target, and how to write a custom annotation, with the key point that an annotation does nothing until code reads it.

What an Annotation Actually Is

Syntactically, an annotation type is declared like an interface with an @. Its elements look like methods but hold data, per the annotations basics lesson and the JLS chapter 9 that defines them:

public @interface Review {
    String reviewer();          // an element: name plus type
    int priority() default 1;   // with a default, the annotation omits it
}

@Review(reviewer = "ada")                 // default priority applies
class PaymentService { }

In practice, three restrictions give annotations their character. First, element types are limited to compile-time constants: primitives, String, Class, enums, nested annotations, and arrays of those. As a result, an annotation can never hold a runtime object. Elements may have defaults, so usage stays short. Finally, the annotation itself is inert: attaching @Review to a class changes no behavior, exactly as a comment would not, until something reads it. The annotations tutorial calls this “information for the compiler or for tools”. In effect, the division of labor is the mental model: annotations are the data, consumers are the effect.

The Built-Ins You Already Use

Four of the five built-ins have appeared in this course already. That is the best evidence of how much daily work they carry:

Annotation Consumer What it does
@Override Compiler Fails the build unless the method genuinely overrides; catches signature typos as compile errors
@FunctionalInterface Compiler Fails the build if a second abstract method appears, protecting every lambda using the interface
@Deprecated Compiler and IDE Warns at usage sites; since Java 9 carries since and forRemoval details
@SuppressWarnings Compiler Silences a specific warning class, like “unchecked”, in the narrowest scope that needs it
@SafeVarargs Compiler A promise that a varargs generic method does not pollute the heap

The @Override story from the inheritance article is the model case. A one-parameter overload typo silently becomes a new method, so dynamic dispatch never finds it, and the bug ships. The annotation converts that runtime surprise into a compile-time error. That is the same trade the @FunctionalInterface article made for functional interfaces, because the compiler holds the contract so production does not have to. The library practice put @Override on every overridden method for exactly this reason, and from here on, so should you.

Retention: How Far Metadata Travels

In practice, retention is the decision that controls everything else: where does the annotation exist? The @Retention meta-annotation takes one of three policies, so the lifecycle is worth drawing once:

  @Retention policy decides how far an annotation travels:

  SOURCE    checked by the compiler, then discarded
            (never leaves the .java file)

  CLASS     written into the .class file (the DEFAULT)
            invisible to the running JVM

  RUNTIME   written into the .class file AND carried into the
            loaded class: visible to reflection

  .java --javac--> .class --class loader--> loaded class --reflection--> value
          SOURCE ends here ^      CLASS ends here ^        RUNTIME reaches here
Policy In source In class file At runtime Typical consumer
SOURCE yes no no Compiler only: @Override
CLASS (default) yes yes no Bytecode tools and analyzers
RUNTIME yes yes yes Frameworks reading via reflection

Here is the trap that produces a full afternoon of debugging roughly once per team, so see it in code before it finds you. For example, here is the wrong code first:

// WRONG: no @Retention, so the default CLASS applies: invisible at runtime
public @interface Audit {
    String value();
}

// a framework looking for @Audit via reflection finds NOTHING,
// silently: the annotation was compiled away from the runtime's view

// RIGHT: state the policy the consumer needs
@Retention(RetentionPolicy.RUNTIME)     // reflection can see it
@Target(ElementType.METHOD)             // and only methods may carry it
public @interface Audit {
    String value();
}

The delta is one line, and the debugging it prevents is the worst kind. The annotation is in the source and even in the class file, yet only the runtime consumer is blind. As a result, the professional rule is mechanical: every custom annotation states @Retention explicitly. Use RUNTIME if anything reads it reflectively and SOURCE if only the compiler should see it. However, use the default CLASS almost never, because almost no application writes its own bytecode analyzers.

@Target and the Other Meta-Annotations

Besides retention, four meta-annotations shape a custom type, and their names say what they do:

Meta-annotation Controls Common values
@Target Where the annotation may appear ElementType.METHOD, TYPE, FIELD, PARAMETER, plus more
@Documented Whether javadoc includes it On by preference for public API annotations
@Inherited Whether subclasses see class-level annotations For type-level contracts only; methods never inherit annotations
@Repeatable Whether one declaration may carry it several times With a container annotation type

@Target is the one you will use most, because it converts “please put this in sensible places” into a compile error. For example, a @Target(ElementType.METHOD) annotation on a field fails the build, at the placement, immediately. Without it, however, an annotation can sit anywhere, and the runtime consumer meets placements it never expected.

Writing and Consuming a Custom Annotation

Here is the complete pattern, declaration to consumer, in one example. The annotation declares retry behavior, while the consumer, a small reflective runner, previews the machinery the next article teaches:

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)      // the consumer reads at runtime
@Target(ElementType.METHOD)              // methods only
public @interface Retry {
    int times() default 2;
    long delayMillis() default 100;
}

class FlakyClient {

    @Retry(times = 3, delayMillis = 50)  // the metadata, attached
    void call() {
        System.out.println("calling...");
    }
}

public class RetryRunner {

    public static void main(String[] args) throws Exception {
        var client = new FlakyClient();

        for (var method : FlakyClient.class.getDeclaredMethods()) {
            Retry retry = method.getAnnotation(Retry.class);   // the read
            if (retry != null) {
                System.out.println(method.getName()
                        + ": retry " + retry.times()
                        + " times, waiting " + retry.delayMillis() + " ms");
                method.invoke(client);       // the effect, article 36's subject
            }
        }
    }
}
call: retry 3 times, waiting 50 ms
calling...

Next, read the consumer in two lines. getAnnotation(Retry.class) returns the annotation instance if the method carries it and null if not. That is why RUNTIME retention is non-negotiable for this pattern. Then the element accessors, retry.times() and retry.delayMillis(), return exactly the values written at the declaration site. Defaults are filled in where the user omitted them. One more detail matters for correctness. getDeclaredMethods sees private methods too, and invoke on a private method requires setAccessible(true). However, module boundaries in Part 4’s modules article restrict that call, and the full rules arrive in the next article.

How Real Systems Do This

Every framework you will meet in this course is, at its heart, a consumer of annotations in Java. As a result, the annotations you will write against them are RUNTIME types by design. Part 7’s JUnit reads @Test and @DisplayName to discover and run tests. Similarly, Part 8’s JPA reads @Entity and @Id to map records and classes to tables. Likewise, Part 9’s validation reads Bean Validation annotations like @NotNull on fields to decide what to check. Meanwhile, the @Override discipline and the @FunctionalInterface check you already use are the compiler-only face of the same idea.

The retention trap has a production story because it ships regularly. In my experience, the version that cost a team three days was a homegrown @Transactional-style annotation. It was declared without @Retention, meaning CLASS, and wrapped around twenty service methods. It was then read by a proxy framework that used reflection. The proxies saw nothing, the “transactions” never began, and the code reviewed green because the annotation was visibly present everywhere. The fix was one line, but the finding it needed was someone finally asking “does the framework actually see this?” instead of “is the annotation there?”. Every custom annotation state its retention, and every consumer have one smoke test proving the metadata arrives.

Compile-time consumption completes the picture, so here is one paragraph for orientation. Annotation processors run inside javac, read SOURCE and CLASS metadata, and generate or validate code. That is how record and builder generators work. This course stays with runtime annotation reading, but when a library’s annotations seem to change your source code, a processor was the consumer.

Decision Framework

  1. Who reads the metadata? The compiler only: SOURCE retention. A framework through reflection: RUNTIME retention, non-negotiable. Nothing reads it yet: do not write the annotation.
  2. Where may it appear? State @Target, the narrowest set that covers real usage: METHOD for behavior, TYPE for classes and interfaces, FIELD for data.
  3. Marker or values? A bare annotation with no elements is a marker, like @Test; elements make it configurable, like @Retry(times = 3). Start with a marker, add elements when consumers need data.
  4. Does a built-in already do the job? @Override, @Deprecated, and @FunctionalInterface exist; a custom twin adds a name and subtracts a guarantee.
  5. Should it repeat? Only with @Repeatable and a real reason, like multiple validators; most contracts are once per declaration.
  6. Should subclasses inherit it? @Inherited works for class-level annotations only, and methods never inherit them; if you need method inheritance, the consumer must walk the hierarchy itself.

When NOT to Use This

  • Do not annotate as documentation. If a consumer does not exist, the annotation is a comment with a syntax tax. Instead, a real comment or javadoc says more for less.
  • Do not encode logic in annotation values that belongs in code. For example, an annotation carrying a method name to invoke, or a branch to take, is config-shaped indirection. The next reader greps the annotation and finds no behavior.
  • Do not create custom annotations that mirror built-ins. A custom @ThreadSafe or @Immutable with no verification tool behind it provides a false sense of checking. Instead, javadoc states the contract without pretending the compiler enforces it.

Common Mistakes

  • Omitting @Retention on a custom annotation: the CLASS default makes it invisible to reflection. As a result, the consumer sees nothing while the source shows the annotation everywhere.
  • Omitting @Target: the annotation applies anywhere, and the reflective consumer meets a placement it cannot handle, at runtime instead of at the declaration.
  • Expecting @Inherited to cover methods: it is class-level only, so a subclass does not carry the parent’s annotated methods to annotation searches.
  • Annotation elements with non-constant defaults: defaults must be compile-time constants, and a runtime-computed default does not compile, by design.
  • Reading annotations without null checks: getAnnotation returns null when absent, so the unguarded .times() call is a NullPointerException in a framework’s startup path.
  • Confusing the annotation with its effect: attaching @Retry changes nothing until a runner reads and acts. Teams that annotate without shipping the consumer have shipped documentation, not behavior.

Key Takeaways

  • Annotations are typed metadata: constant values attached to declarations, inert until a consumer reads them.
  • The built-ins carry the daily load: @Override verifies overrides, @Deprecated warns at call sites, @SuppressWarnings silences narrowly, @FunctionalInterface guards lambda contracts.
  • @Retention decides the lifetime: SOURCE for the compiler, CLASS by default, RUNTIME for reflection, and every custom annotation states it explicitly.
  • @Target constrains placement, converting “sensible locations” into compile errors, and @Inherited, @Repeatable, and @Documented complete the meta-annotations.
  • Custom annotations come with their consumer: define the reader, then the type, and smoke test that the metadata actually arrives.
  • Frameworks you will meet later, JUnit, JPA, Bean Validation, are annotation consumers reading RUNTIME metadata through reflection.
  • An annotation is data, not behavior: if nothing reads it, it is a comment wearing syntax.

FAQ

What are annotations in Java?

Typed metadata attached to declarations: constant values declared in an @interface type and read by a consumer, the compiler, a build tool, or a framework at runtime. They change nothing by themselves; every effect belongs to the reader.

What is @Retention in Java?

A meta-annotation on annotation types that decides how far the metadata travels: SOURCE ends at the compiler, CLASS (the default) is written to the class file but invisible at runtime, and RUNTIME is visible to reflection.

What is the default retention policy of an annotation?

CLASS: the annotation is stored in the .class file but not carried into the loaded class, so reflection cannot see it. This is why a custom annotation without an explicit @Retention(RUNTIME) is silently invisible to frameworks.

What does @Override do in Java?

It asks the compiler to verify that the method genuinely overrides a supertype method. A signature typo that would silently create an overload instead becomes a compile-time error at the exact declaration.

How do I create a custom annotation in Java?

Declare an @interface type, state @Retention(RUNTIME) if anything reads it reflectively, state @Target for where it may appear, give elements defaults where sensible, and write the consumer that reads it, via getAnnotation, before relying on it.

Conclusion

In the end, annotations in Java complete a pattern this course has used since Part 2: contracts moved from comments to code the tooling can see. The built-ins guard the compiler’s contracts, while the meta-annotations decide where metadata lives and where it may sit. Meanwhile, custom types are cheap once the consumer exists.

Next, the following article opens the consumer’s toolbox: reflection. That API reads classes, methods, fields, and annotations at runtime, powers every framework’s magic, and earns every caution about cost and encapsulation.

An annotation is a promise someone else reads. Make sure the reader exists, and make sure it can see what you wrote.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *