Annotationen in Java sind kleine Marker direkt im Code, die Zusatzinformationen tragen. Du kennst sie vermutlich schon: @Override, @Deprecated oder @SuppressWarnings. Das sind keine Kommentare und auch keine Magie im Sinne von "der Compiler macht irgendwas Unfassbares". Es ist eher wie ein sauberer Zettel am Code: "Dieses Element hat eine bestimmte Bedeutung". Entscheidend ist, wer diesen Zettel liest - der Compiler, dein IDE-Inspektor, ein Build-Tool oder ein Framework zur Laufzeit.

Wenn du eine Klasse, Methode oder ein Feld mit einer Annotation versiehst, änderst du nicht automatisch die Logik. Du gibst Metadaten dazu. Manche Werkzeuge reagieren darauf und erzeugen daraus Verhalten: IntelliJ zeigt Warnungen, Maven-Plugins prüfen Regeln, und in JavaEE-Umgebungen wie WildFly werden Komponenten gefunden und verdrahtet. Der Code bleibt trotzdem Java, nur mit zusätzlichen Hinweisen, die sich maschinell auswerten lassen.

 

Was Annotationen genau machen

Technisch gesehen sind Annotationen Typen. Sie werden mit @interface definiert und können an Programmstellen angebracht werden, zum Beispiel an Klassen, Methoden, Parametern oder Feldern. Eine Annotation kann Werte tragen, sogenannte Elemente. Diese sehen aus wie Methoden, liefern aber keine Logik, sondern nur eine Art Konfiguration. Wenn du nichts angibst, kannst du Defaults definieren. Das wirkt dann wie ein Parameter mit Standardwert.

Das Wichtigste ist die Frage: Wann sind die Daten verfügbar? Java unterscheidet drei typische Ebenen, und du steuerst das mit @Retention. SOURCE bedeutet: Die Annotation ist nur im Quelltext da und verschwindet beim Kompilieren. CLASS bedeutet: Sie landet in der .class-Datei, ist aber zur Laufzeit nicht per Reflection sichtbar. RUNTIME bedeutet: Sie ist auch zur Laufzeit noch vorhanden und kann über Reflection ausgelesen werden. Viele Einsteiger stolpern hier, weil sie eine Annotation lesen wollen, aber @Retention nicht auf RUNTIME gesetzt ist.

Der zweite Klassiker ist @Target. Damit begrenzt du, wo deine Annotation überhaupt erlaubt ist. Ohne Begrenzung ist vieles möglich, aber nicht vieles sinnvoll. Wenn du eine Marker-Annotation für Methoden definierst, willst du vermeiden, dass sie plötzlich auf Feldern oder lokalen Variablen auftaucht. Diese Begrenzung ist weniger Formalie als API-Design: Du sagst damit, wie deine Annotation gedacht ist.

Ein Beispiel aus dem Alltag: @Override wird vom Compiler genutzt, um zu prüfen, ob du wirklich etwas überschreibst. Das ist kein Runtime-Feature. Der Effekt ist die zusätzliche Prüfung beim Build und damit weniger Fehler. @Deprecated ist ähnlich: Der Compiler und die IDE warnen, und Tools können das beim Build blockieren, wenn ihr das so konfiguriert. @SuppressWarnings ist wieder anders: Du sagst dem Tooling, dass eine bestimmte Warnung bewusst in Kauf genommen wird. Das ist praktisch, aber nutze es sparsam und möglichst eng am konkreten Code, sonst überdeckst du echte Probleme.

 

Woher das Verhalten kommt - die "magic" dahinter

Die spannende Stelle ist nicht die Annotation selbst, sondern der Auswerter. In JavaEE ist das oft der Container. WildFly scannt beim Deployment Klassen, liest Annotationen und erstellt daraus Metadaten. Daraus baut er dann zum Beispiel CDI-Beans, JPA-Entities oder REST-Endpunkte. Das passiert nicht, weil Java bei @Path plötzlich anders läuft, sondern weil das Framework beim Start eine Liste dieser Marker sammelt und daraus Konfiguration ableitet.

Zur Laufzeit passiert das meistens über Reflection. Das Framework fragt eine Klasse oder Methode, welche Annotationen vorhanden sind, und entscheidet dann, was zu tun ist. Genau deshalb ist @Retention(RUNTIME) bei vielen Framework-Annotationen gesetzt. Häufig kombinieren Frameworks das mit Caching: Sie scannen einmal beim Start, speichern die Ergebnisse in internen Strukturen und arbeiten danach ohne ständige Reflection. Der Code fühlt sich dadurch "dekoriert" an, obwohl technisch gesehen nur Metadaten gelesen werden.

Manchmal nutzt ein Framework zusätzlich Proxies oder Bytecode-Generierung, um Verhalten einzuschleusen. Das siehst du typischerweise bei Dingen wie Transaktionen, Security oder Interceptors: Du annotierst eine Methode, aber der Code, der am Ende aufgerufen wird, ist ein Wrapper, der vor und nach deinem Methodenbody zusätzliche Logik ausführt. In solchen Fällen ist die Annotation nur das Signal, ab wann ein bestimmter Wrapper greifen soll.

Es gibt außerdem die zweite Welt: Auswertung beim Kompilieren. Annotation Processing kann während des Builds Quellcode generieren oder Validierungen durchführen. Das ist die Art von "magic", die du am Ende als ganz normalen Java-Code siehst, nur dass er automatisch entsteht. In Maven läuft das über den normalen Compiler-Workflow. In IntelliJ merkst du es daran, dass generierte Klassen im target-Verzeichnis landen oder dass Fehler schon beim Tippen auftauchen, weil ein Processor Regeln prüft. Und prüfe im Git-Diff, ob generierter Code überhaupt eingecheckt werden soll. Der Vorteil ist, dass du Feedback früh bekommst oder Boilerplate sparst. Der Preis ist, dass du wissen musst, wo Code herkommt, damit Debugging und Code-Review sauber bleiben.

 

Eigene Annotationen erstellen und nutzen

Eine eigene Annotation schreibst du in wenigen Zeilen. Das hier ist bewusst simpel und realistisch, weil du damit sofort testen kannst, wie Auswertung funktioniert:

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audited {
    String value() default "";
}

Du legst fest, dass sie zur Laufzeit sichtbar ist und nur an Methoden hängen darf. Dann nutzt du sie wie jede andere Annotation:

public class UserService {

    @Audited("user-create")
    public void createUser(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name");
        }
    }
}

Damit passiert erst mal gar nichts. Jetzt kommt der Teil, der sie lebendig macht: Du liest sie aus und reagierst darauf. Für ein kleines Demo reicht Reflection:

import java.lang.reflect.Method;

Method m = UserService.class.getMethod("createUser", String.class);
Audited a = m.getAnnotation(Audited.class);

if (a != null) {
    System.out.println("Audit: " + a.value());
}

Genau so startet auch viel Framework-Logik, nur in größerem Stil. In echten Anwendungen willst du Reflection nicht überall selbst schreiben. Du baust dir stattdessen einen zentralen Mechanismus, der an einer Stelle entscheidet, was passiert. In JavaEE sind Interceptors ein typischer Weg: Du markierst eine Methode oder Klasse, und der Container sorgt dafür, dass dein Interceptor aufgerufen wird. Die Annotation ist dabei wieder nur das Signal, nicht die Implementierung.

Wenn du deine Annotation langfristig sauber halten willst, lohnt es sich, die Meta-Annotationen zu kennen, die in Projekten häufig gebraucht werden. @Documented steuert, ob deine Annotation in der Javadoc sichtbar ist. @Inherited sorgt dafür, dass eine Klassenannotation beim Erben mitwandert, was manchmal hilfreich ist, manchmal aber auch überrascht. @Repeatable erlaubt mehrfaches Verwenden derselben Annotation an einer Stelle, etwa wenn du mehrere Regeln oder Tags vergeben willst. Und mit @Target und @Retention gibst du der Annotation einen klaren Rahmen, der später am stärksten über Verständlichkeit und Nutzbarkeit entscheidet.

Ein pragmatischer Tipp aus der Praxis: Entscheide früh, ob du Runtime-Auswertung wirklich brauchst. RUNTIME ist bequem, aber es führt schnell dazu, dass Leute "einfach mal" per Reflection in jeder Ecke suchen. Das ist nicht automatisch langsam, aber es wird schnell unübersichtlich. Wenn du Annotationen als Projekt-API definierst, ist die eigentliche Qualität nicht der Marker, sondern die klare Auswertungsstelle, gute Fehlermeldungen und eine saubere Begrenzung, wo die Annotation Sinn ergibt.

 

Fazit

Annotationen sind Metadaten direkt am Code. Sie sind nützlich, weil sie nah an dem liegen, was sie beschreiben, und weil Tools sie zuverlässig auswerten können. Die "magic" entsteht erst durch den Leser: Compiler, IDE, Build-Prozess oder Framework. Wenn du das trennst - Marker hier, Auswertung dort - wird das Thema sofort greifbar. Eigene Annotationen zu schreiben ist simpel. Der professionelle Teil ist, sie sauber zu begrenzen (@Target), ihren Lebenszyklus zu wählen (@Retention) und die Auswertung so zu bauen, dass sie nachvollziehbar bleibt.