1. Annotationen als Metadaten des Containers
Wenn man anfängt, Spring-Code zu schreiben, ist es sehr leicht, der Illusion zu verfallen: „Ich habe @Autowired gesetzt, also wird Java schon verstehen, dass hier eine Abhängigkeit injiziert werden muss.“ Das ist ungefähr so, als würde man einen Aufkleber „Bitte reparieren“ auf seinen Laptop kleben und erwarten, dass er von selbst zur Reparatur geht. Eine Annotation ist lediglich eine Markierung im Bytecode, also Metadaten, kein ausführbarer Code. Damit aus Metadaten tatsächliches Verhalten entsteht, braucht es jemanden, der diese Metadaten liest und entsprechende Aktionen ausführt. In unserem Fall ist dieser „Jemand“ der Spring-Container – genauer gesagt, seine infrastrukturellen Handler.
Wir haben bereits gesehen, dass der Container in das Leben eines Beans rund um die Init-Phase eingreifen und sogar einen Wrapper anstelle des ursprünglichen Objekts zurückgeben kann. Jetzt wenden wir dieselbe Logik auf die bekannten Annotationen an: Dann wirken @Autowired, @PostConstruct und @EventListener nicht mehr wie „das funktioniert einfach so von selbst“.
Wenn man den Unterschied selbst spüren möchte, kann man ein kurzes Gedankenexperiment machen: Ein mit new erstelltes Objekt ist einfach ein Objekt. Es hat Annotationen, aber niemand hat sie gelesen, niemand hat Abhängigkeiten injiziert, niemand hat Init-Callbacks aufgerufen, niemand hat ihn als Listener registriert. Ein vom Container erstellter Bean hingegen durchläuft die Pipeline und „erwacht zum Leben“.
Mini-Demo (auf der Ebene „Annotation existiert, aber tut selbst nichts“):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import java.lang.reflect.Method;
public class AnnotationProbe {
public static void main(String[] args) throws Exception {
// Betrachten die Annotation per Reflection: es sind nur Metadaten im Bytecode
Method m = ReportOutputManager.class
.getDeclaredMethod("setResourceLoader", ResourceLoader.class);
// Prüfen, ob @Autowired vorhanden ist, aber es findet keine DI-Aktion statt
System.out.println(m.isAnnotationPresent(Autowired.class)); // true
// Und das war's. Es fand keine "Injektion" statt.
}
}
Der entscheidende Gedanke: Das Vorhandensein von @Autowired ist lediglich ein Hinweis darauf, dass jemand diesen Method als Injection Point behandeln könnte. Ob dies geschieht, hängt davon ab, ob die Container-Infrastruktur existiert und ob der Bean sie durchlaufen hat.
2. Infrastrukturelle Handler des ApplicationContext
Wenn Sie einen AnnotationConfigApplicationContext starten, denken Sie normalerweise, dass im Container nur Ihre Services wie OrderPlacementService, AuditService, NotificationTemplateCatalog leben. Tatsächlich leben jedoch neben ihnen eine ganze Reihe von Dienst-Bean, die die gesamte Annotation-Modell unterstützen. Sie werden selten von Hand geschrieben, aber genau sie machen Spring zu Spring.
Ein wichtiger psychologischer Punkt: Diese „magischen“ Handler sind nichts Mystisches. In den meisten Fällen sind es gewöhnliche Beans, die lediglich spezielle Schnittstellen wie BeanFactoryPostProcessor und BeanPostProcessor (und einige weitere Verträge) implementieren. Der Container weiß, wann er sie aufrufen muss, und tut dies in der richtigen Phase der Startup-Pipeline.
Man kann sogar „anfassen“, dass sie im Kontext existieren. Die Infrastruktur-Prozessoren haben interne Namen (sie sehen aus wie lange Zeichenketten, weil Spring nicht versehentlich mit unseren Namen kollidieren wollte):
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class InfraBeansProbe {
public static void main(String[] args) {
// Wichtig: Kontext startet und registriert seine Infrastruktur-Beans selbst
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// AutowiredAnnotationBeanPostProcessor – derjenige, der @Autowired ausführt
System.out.println(
ctx.containsBean("org.springframework.context.annotation.internalAutowiredAnnotationProcessor")
); // true
// CommonAnnotationBeanPostProcessor – verarbeitet z.B. @PostConstruct usw.
System.out.println(
ctx.containsBean("org.springframework.context.annotation.internalCommonAnnotationProcessor")
); // true
// EventListenerMethodProcessor – scannt @EventListener und registriert Listener
System.out.println(
ctx.containsBean("org.springframework.context.event.internalEventListenerProcessor")
); // true
}
}
}
Hier ist es wichtig, nicht alle Annotationen in einen Topf zu werfen. @Configuration, @ComponentScan und @Import werden früh wirksam, während der Container noch die BeanDefinition-Map erweitert; hier arbeiten Definition-Level-Mechanismen wie ConfigurationClassPostProcessor, der neue Definitionen nachregistrieren kann. @Autowired und @PostConstruct werden später wirksam, wenn das konkrete Objekt bereits erstellt wurde und die Init-Phase durchläuft; das ist bereits Instance-Level-Verarbeitung durch AutowiredAnnotationBeanPostProcessor und CommonAnnotationBeanPostProcessor. @EventListener steht etwas abseits: Der Container findet solche Methoden beim Start und registriert sie als Ereignis-Handler, also ist das auch nicht „die Annotation selbst zaubert“.
Wenn man es kurz zusammenfasst, hier eine praktische „Entsprechungskarte“ (nicht als Nachschlagewerk, sondern als Kompass – wohin man schauen sollte, wenn man sich fragt „warum funktioniert das?“):
| Was Sie im Code schreiben | Was im Container passieren soll | Art des Mechanismus |
|---|---|---|
| @Configuration, @ComponentScan, @Import | Container muss die Konfiguration lesen und BeanDefinition für die gefundenen Komponenten erstellen | frühe Definition-Level-Verarbeitung durch BeanDefinitionRegistryPostProcessor / ConfigurationClassPostProcessor |
| @Autowired | Container muss Injection Points finden und die Injektion durchführen | BPP-Ebene (Arbeit mit Instanz + DI) |
| @PostConstruct | Container muss den Init-Callback nach der Dependency-Injection aufrufen | BPP-Ebene (Teil der Init-Phase) |
| @EventListener | Container muss Listener-Methoden finden und sie vor der Veröffentlichung von Ereignissen registrieren | separate Startup-Infrastruktur, die Listener-Methoden findet und registriert |
| Auto-Wrapping des Beans | Container muss aus getBean() einen „Wrapper“ zurückgeben, der Verhalten hinzufügt | BPP-Ebene (nach der Initialisierung) |
Beachten Sie einen wichtigen Denkstil: Wir hören auf zu sagen „die Annotation funktioniert“ und beginnen zu sagen „der Container hat die Arbeit erledigt, die die Metadaten verlangt haben“. Das ist die erwachsene mentale Modell.
3. Mechanik von @Autowired
@Autowired hat den Ruf des „offensichtlichsten“ Mechanismus in Spring. Aber gerade wegen dieser Offensichtlichkeit wird es oft zu Magie: Der Anfänger denkt, @Autowired sei fast wie ein Sprachschlüsselwort. In Wirklichkeit ist es viel profaner: Es gibt eine infrastrukturelle Container-Komponente, die während der Bean-Erstellung Injection Points sucht und die Injektion über die BeanFactory durchführt (also über dasselbe System der Abhängigkeitsauflösung, das Sie bereits aus den Themen Candidates, @Primary, @Qualifier usw. kennen).
Um die Field Injection nicht als Gewohnheit zu fördern, zeigen wir dies anhand der Setter Injection, aber betonen: Dies ist eine Demonstration der Mechanik, nicht „wie man es immer machen sollte“.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
@Component
public class ReportOutputManager {
// Feld bleibt null, bis der Container die Injektion durchführt
private ResourceLoader resourceLoader;
@Autowired
public void setResourceLoader(ResourceLoader resourceLoader) {
// Container findet diese Methode als Injection Point und ruft sie bei der Bean-Erstellung auf
this.resourceLoader = resourceLoader;
}
public boolean isReady() {
// Einfacher Indikator, ob die Abhängigkeit injiziert wurde
return resourceLoader != null;
}
}
Jetzt der wichtigste Vergleich: new vs. Container-managed.
public class WiringRealityCheck {
public static void main(String[] args) {
// Objekt manuell erstellt: Container nicht beteiligt, DI nicht ausgeführt
ReportOutputManager plain = new ReportOutputManager();
System.out.println(plain.isReady()); // false
}
}
Objekt erstellt, Annotation in der Klasse vorhanden, aber keine Injektion – weil der Container nicht beteiligt war. Jetzt dasselbe über den Kontext:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class WiringRealityCheckSpring {
public static void main(String[] args) {
// Kontext erstellt: Danach wird das Objekt erstellt und durch die BPP-Kette geschleust
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
ReportOutputManager manager = ctx.getBean(ReportOutputManager.class);
// Hier wurde der Setter bereits vom Container aufgerufen
System.out.println(manager.isReady()); // true
}
}
}
Hier ist das „Wunder“ sehr konkret: Der Container hat ein Objekt erstellt, dann seine Handler durchlaufen, @Autowired auf der Methode gesehen, einen geeigneten ResourceLoader im Kontext gefunden und den Setter aufgerufen. Das ist nicht die Sprache, nicht die JVM, nicht „irgendwie von selbst“. Es ist eine kontrollierte Phase der Pipeline.
Nützliche Verbindung zu den vorherigen Vorlesungen des Tages: Eine solche Funktionalität ist nur möglich, weil der Container die „Instanzverarbeitung“ über BeanPostProcessor erlaubt. Genau hier können wir unser eigenes Verhalten „einmischen“: zum Beispiel protokollieren, dass der Bean bereit ist, oder seine Erstellung loggen, oder sogar das Objekt durch einen Wrapper ersetzen.
4. Mechanik von @PostConstruct
Das Thema Lifecycle-Callbacks (@PostConstruct, @PreDestroy, initMethod, destroyMethod) konnte früher wie „eine weitere Annotation, die man sich merken muss“ wirken. Jetzt können wir es anders erklären: Der Init-Callback ist Teil einer streng definierten Lebenszyklusphase, und er wird ausgelöst, weil der Container im richtigen Moment die richtige Methode aufruft. Die JVM ist nicht verpflichtet, @PostConstruct aufzurufen. Mehr noch: Wenn das Objekt mit new erstellt wurde, ruft die JVM nichts auf (und sollte es auch nicht).
Machen wir einen kurzen und sehr anschaulichen Test:
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
@Component
public class TemplateCatalogProbe {
public TemplateCatalogProbe() {
// Konstruktor wird sowohl bei new als auch bei der Bean-Erstellung durch den Container aufgerufen
System.out.println("constructor"); // constructor
}
@PostConstruct
void init() {
// Diese Methode wird genau vom Container (in der Init-Phase) aufgerufen, nicht von der JVM "von selbst"
System.out.println("postConstruct"); // postConstruct
}
}
Und jetzt vergleichen wir die manuelle Erstellung und die Erstellung über Spring:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class PostConstructDemo {
public static void main(String[] args) {
System.out.println("=== new ==="); // === new ===
new TemplateCatalogProbe(); // constructor
System.out.println("=== spring ==="); // === spring ===
try (var ctx = new AnnotationConfigApplicationContext(TemplateCatalogProbe.class)) {
// Wichtig: Beim Start des Kontexts wird der Bean erstellt,
// dann führt der Container die Init-Phase aus und ruft @PostConstruct auf
}
}
}
In diesem kleinen Fragment „sehen“ Sie die Pipeline mit den Augen von System.out.println(). Bei new sehen Sie nur constructor, bei Spring dagegen sowohl constructor als auch postConstruct. Das bedeutet, der Init-Callback ist keine „Sprachmagie“, sondern Teil der Prozedur initializeBean(...) innerhalb des Containers, um die sich die BeanPostProcessor-Kette dreht.
Und eine weitere wichtige Schlussfolgerung, die besonders in echten Projekten nützlich ist: Wenn @PostConstruct plötzlich nicht funktioniert, bedeutet das fast immer nicht „die Annotation ist kaputt“, sondern „das Objekt wurde kein Bean“ oder „der Kontext hat die Initialisierungsphase nicht erreicht“ (zuvor aufgrund eines Wiring-Fehlers abgestürzt, z. B.).
5. Mechanik von @EventListener
Bei @EventListener passiert eine ähnliche Geschichte: Es scheint, als wäre es „nur eine Annotation auf einer Methode“. Aber eine Methode mit @EventListener ist keine eingebaute Java-Funktion. Es ist eine gewöhnliche Methode, die vom Container gefunden und als Ereignis-Handler registriert werden muss. Erst dann, wenn im ContextFlow die Veröffentlichung eines Ereignisses publisher.publishEvent(...) aufruft, versteht der Container: „Aha, hier ist die Liste der Handler, jetzt werde ich sie aufrufen“.
Beginnen wir mit einem einfachen Domain-Ereignis (in unserem Projekt ist dies bereits ein bekanntes Modell):
public record OrderCreatedEvent(String orderId) {
}
Jetzt ein Listener, der bei einem Ereignis etwas ins Audit schreibt (in einem echten Projekt wäre das AuditWriter, hier reicht println(), um den Aufruf zu sehen):
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onCreated(OrderCreatedEvent event) {
// Diese Methode wird NICHT aufgerufen, weil es eine Annotation gibt,
// sondern weil der Container sie als Event-Handler registriert hat
System.out.println("AUDIT: created " + event.orderId()); // AUDIT: created order-1
}
}
Und die Publisher-Demo, die das Ereignis über ApplicationEventPublisher veröffentlicht:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
public class DemoPublisher {
private final ApplicationEventPublisher publisher;
public DemoPublisher(ApplicationEventPublisher publisher) {
// Publisher wird vom Container als infrastrukturelle Abhängigkeit des Kontexts injiziert
this.publisher = publisher;
}
public void run() {
// Wichtig: Das Ereignis wird über den Container-Publisher veröffentlicht,
// erst dann kann Spring es an alle registrierten Listener zustellen
publisher.publishEvent(new OrderCreatedEvent("order-1"));
}
}
Starten:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class EventListenerDemo {
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(
DemoPublisher.class,
AuditOrderEventsListener.class
)) {
// Den Publisher als Bean holen: Das garantiert, dass DI und die Registrierung der Listener bereits erfolgt sind
ctx.getBean(DemoPublisher.class).run(); // AUDIT: created order-1
}
}
}
Was hier besonders wichtig für das mentale Modell ist: @EventListener „aktiviert“ nicht die Ereignisse. Ereignisse als Mechanismus existieren bereits im Kontext (ApplicationEventPublisher ist als Teil des ApplicationContext vorhanden). Die Annotation ist lediglich eine Möglichkeit, den Container zu bitten: „Bitte registriere diese Methode als Handler“.
Und hier entsteht eine großartige Brücke zum Thema des Tages: Damit @EventListener funktioniert, gibt es im Container eine infrastrukturelle Verarbeitung beim Start, die die Beans durchgeht, solche Methoden sucht und sie mit dem Event-Multicaster verbindet. Ereignisse sehen aus wie „einfach publishEvent“, aber tatsächlich ist da nichts „einfach“: Vorher muss der Container ein Listener-Register erstellen, und auch das ist Teil der Startup-Pipeline.
6. Auto-Wrapping von Beans und Auto-Proxying
Wenn der Container früher wie ein Ort wirkte, der einfach „ein Objekt erstellt und zurückgibt“, kann man jetzt eine wichtige Wendung hinzufügen. Nach der Init-Phase kann er in den Kontext nicht das Target selbst, sondern einen Wrapper darum legen. Am Beispiel von NotificationSender war bereits zu sehen, dass BeanPostProcessor in der Lage ist, zentral technisches Verhalten hinzuzufügen, ohne die ursprüngliche Klasse umzuschreiben.
Wenn um NotificationSender ein technischer Wrapper TrackingNotificationSender existiert, sieht das auf der Mechanik-Ebene so aus:
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof NotificationSender sender) {
return new TrackingNotificationSender(sender);
}
return bean;
}
Nach einem solchen Schritt erhalten die anderen Teile der Anwendung bereits den Wrapper. Wenn anstelle einer manuellen Java-Wrapper ein Runtime-Proxy verwendet wird, bleibt das Prinzip dasselbe: Der Container ersetzt das endgültige Objekt und fügt technisches Verhalten um das Target herum hinzu. Für dieses Thema reicht es jetzt, die Idee zu sehen – der Bean im Kontext muss nicht unbedingt eine „nackte Instanz“ Ihrer Klasse sein.
Deshalb kann man im Debugger, in getClass() und manchmal sogar bei instanceof-Prüfungen nicht das sehen, was man aufgrund der ursprünglichen Klasse erwartet. Das ist nicht „Spring spinnt schon wieder“, sondern eine direkte Folge davon, dass der Container ein bereits verarbeitetes Objekt zurückgegeben hat.
7. Diagnose: „Annotation hat nicht funktioniert“
Wenn im Projekt etwas nicht funktioniert (nicht injiziert wurde, nicht initialisiert wurde, kein Listener aufgerufen wurde), ist die erste Reaktion eines Anfängers, das Framework zu verdächtigen. Das ist normal: Spring ist groß, Stacktraces sind lang, und der Kaffee geht aus. Aber in 95 % der Fälle ist die Ursache profaner und hängt mit der Pipeline zusammen, die wir gerade untersuchen.
Die Diagnose beginnt am besten mit der Frage: „Ist dieses Objekt überhaupt ein Container-managed Bean?“ Wenn Sie es mit new erstellt haben, existiert es für den Container nicht. Keine @Autowired, @PostConstruct, @EventListener müssen standardmäßig für solche Objekte funktionieren, weil das Objekt nicht die Startup-Pipeline und die Prozessorkette durchlaufen hat. In ContextFlow ist das besonders wichtig, weil wir bewusst die Regel befolgen: Geschäftsservices erhalten keinen ApplicationContext und erstellen sich keine Abhängigkeiten selbst. Haben Sie die Regel verletzt, sind Sie buchstäblich „aus der Welt von Spring ausgestiegen“.
Zweite Frage: „Ist der Kontext sicher erfolgreich gestartet und bis zur Phase der Erstellung von Singleton-Beans gelangt?“ Wenn der Kontext mit einem Startup-Fehler abgestürzt ist, sind die Annotationen nicht „kaputt“, sondern der Container hat die Phase, in der sie verarbeitet werden, einfach nicht erreicht. Deshalb ist das Modell „register → refresh → BFPP → Beans erstellen → BPP → init“ so wichtig.
Dritte Frage: „Wird das Ereignis über den Container veröffentlicht?“ Manchmal nimmt man new SimpleApplicationEventMulticaster() (oder ruft die Listener-Methode direkt auf) und erwartet, dass @EventListener „irgendwie greift“. Aber @EventListener ist eine Geschichte über die Registrierung eines Handlers innerhalb des Containers und über die Veröffentlichung von Ereignissen über ApplicationEventPublisher. Außerhalb des Containers ist das einfach eine Methode, die niemand aufruft.
Und ein letzter, aber sehr praktischer Punkt: Wenn Sie selbst einen BeanPostProcessor geschrieben haben (z. B. zur Diagnose), machen Sie die Verarbeitungskriterien eng. Ein zu breiter BPP, der „alles Mögliche anfasst“, kann das Gefühl erzeugen, dass „bei mir die Hälfte von Spring kaputt ist“. In Wirklichkeit haben Sie einfach den halben Kontext durch Wrapper ersetzt, und jetzt sehen equals/hashCode, Typen im Debugger und Aufrufketten anders aus.
8. Typische Fehler beim Arbeiten mit Annotationen
Fehler Nr. 1: Objekt mit new erstellt, aber @Autowired und @PostConstruct werden erwartet.
Dies ist der häufigste und ehrlichste Grund für „Annotation hat nicht funktioniert“. Der Container war nicht an der Erstellung des Objekts beteiligt, also wurde die BPP-Kette nicht gestartet, also hat niemand Abhängigkeiten injiziert und den Init-Callback aufgerufen. Wenn Sie ein Objekt mit Spring-Verhalten benötigen, muss es ein Bean sein und vom Container erstellt werden.
Fehler Nr. 2: @EventListener gesetzt, aber Ereignisse werden am Container vorbei veröffentlicht.
Manchmal sieht das so aus: Sie haben den Listener als Bean erstellt, aber den Publisher mit new in einer Utility-Klasse erstellt, und darin gibt es überhaupt keinen ApplicationEventPublisher. Die Folge: Sie veröffentlichen entweder gar kein Ereignis oder veröffentlichen es nicht über Spring. Das Spring-Ereignismodell ist intern; es funktioniert, wenn die Veröffentlichung über den ApplicationEventPublisher aus dem Kontext erfolgt.
Fehler Nr. 3: Der Kontext stürzt beim Start ab, und Sie suchen das Problem darin, dass „@PostConstruct nicht aufgerufen wurde“.
Wenn der ApplicationContext aufgrund eines Wiring-Fehlers (z. B. Bean nicht gefunden oder zu viele Kandidaten) abgestürzt ist, muss keine Lifecycle-Phase abgeschlossen werden. In solchen Situationen ist es wichtig, in Startphasen zu denken: Stellen Sie zuerst sicher, dass der Kontext überhaupt bis zur Erstellung der Singleton-Beans gelangt ist, und prüfen Sie erst dann Init/Destroy-Callbacks.
Fehler Nr. 4: Ein zu breiter BeanPostProcessor geschrieben und versehentlich die halbe Anwendung verändert.
Ein BPP, der alle Beans wahllos umwickelt, sieht aus wie „eine coole universelle Sache“, aber in einem Lernprojekt und in echtem Code ist das meist ein Weg ins Chaos. Der Container beginnt, unerwartete Runtime-Objekte zurückzugeben, im Debugger tauchen überall Wrapper auf, und einige Mechanismen (insbesondere typbezogene) verhalten sich ungewohnt. Ein guter BPP hat normalerweise ein enges Kriterium: nach Typ, Name, Paket oder Marker-Interface.
Fehler Nr. 5: Metadaten-Ebene und Instanzebene verwechseln.
Wenn Sie versuchen, ein Objekt in BeanFactoryPostProcessor zu „korrigieren“, machen Sie fast garantiert etwas falsch: BFPP lebt in der Welt der BeanDefinition. Und umgekehrt: Wenn Sie versuchen, eine Bean-Definition innerhalb eines BeanPostProcessor zu „korrigieren“, sind Sie bereits zu spät – das Objekt ist erstellt, und Änderungen an der Beschreibung werden entweder nicht angewendet oder haben seltsame Nebeneffekte.
GO TO FULL VERSION