1. Rolle der Init-Phase und des Konstruktors
Wir haben bereits den eigentlichen Objekterstellungsvorgang von dem Zeitpunkt getrennt, an dem ein Bean tatsächlich einsatzbereit ist. Nun müssen wir verstehen, was der Container in diese Lücke einfügt.
Wenn Sie sich gerade erst mit DI angefreundet haben, möchten Sie sicher sagen: „Nun, ich erhalte die Abhängigkeiten im Konstruktor. Also ist der Konstruktor der ideale Ort, um alles sofort vorzubereiten.“ Die Logik ist verständlich, aber in der Spring-Welt führt dies oft zu Code, der schwer zu lesen, zu testen und zu warten ist. Die Init-Phase ist eine Möglichkeit, die Vorbereitung des Objekts explizit und vom Container gesteuert zu machen, anstatt sie im Konstruktor zu verstecken.
Der Konstruktor in DI-freundlichem Code erfüllt gut die Rolle eines „Vertrags“: Er sagt, ohne was das Objekt nicht leben kann. Er sollte schnell und vorhersagbar sein, wie ein guter Morgen eines Entwicklers: Laptop aufgeklappt – und nichts brennt. Die Vorbereitung des inneren Zustands hingegen ist oft ein separater Schritt. Beispielsweise möchte ein Objekt möglicherweise einen internen Index aufbauen, den Cache aufwärmen, etwas in seinen Abhängigkeiten prüfen oder ein Arbeitsverzeichnis für die Ausgabe von Berichten vorbereiten. Das ist schon näher an „vor dem Start sicherstellen, dass das Netzkabel eingesteckt ist“ als an der einfachen Objekterstellung.
Es ist nützlich, eine einfache Grenze im Kopf zu haben: Der Konstruktor ist für die Aufnahme der obligatorischen Abhängigkeiten, die Init-Phase ist für die Überführung des Objekts in einen betriebsbereiten Zustand. Dies ist kein „offizielles Gesetz der Spring-Physik“, sondern eher eine ingenieurtechnische Gewohnheit, die die Anzahl der „seltsamen“ Fehler beim Start drastisch reduziert.
Eine kleine Tabelle zur Orientierung:
| Wo die Logik lebt | Was dort gut aussieht | Was dort verdächtig aussieht |
|---|---|---|
| Konstruktor | Abhängigkeiten speichern, leichte Argumentprüfungen | schwere Vorbereitung, IO, „zufälliger“ Start eines Geschäftsszenarios |
| Init-Phase | Cache/Index vorbereiten, validieren, Verzeichnis erstellen | Benachrichtigungen an „alle Kunden, dass der Dienst gestartet ist“ senden, Berichte „für alle Fälle“ generieren |
Damit es nicht wie reine Philosophie erscheint, machen wir eine einfache Beobachtung: Der Konstruktor wird immer dann aufgerufen, wenn Sie new verwenden, während die Init-Phase nur dann aufgerufen wird, wenn das Objekt als Bean vom Container erstellt wurde. Und hier zeigt Spring seine Superkraft: Es kann garantieren, dass die Init-Logik genau zum richtigen Zeitpunkt aufgerufen wird und nicht „irgendwo vergessen – und es ist nicht passiert“.
2. @PostConstruct
@PostConstruct ist ein fast idealer Start für einen Anfänger. Erstens liest es sich wie ein menschlicher Satz: „nach der Erstellung – mach dies“. Zweitens ist es keine reine Spring-Annotation, sondern Teil der Jakarta-Annotationen (historisch JSR-250), wirkt also neutraler. Drittens erscheint im Klassencode eine offensichtliche Stelle, an der seine Vorbereitung liegt, und nicht „Magie in der Konfiguration über einen String-Namen“.
Für @PostConstruct reicht es in der Regel, sich drei Dinge zu merken: Die Methode muss parameterlos sein, sie muss void sein, und sie soll die Vorbereitung des Objekts beschreiben, nicht den Start des Hauptanwendungsszenarios. Wenn die Methode eine Ausnahme wirft, wird Spring in der Regel den Start des Kontexts abbrechen, was für Überprüfungen und Fail-Fast-Verhalten normalerweise sehr nützlich ist: Besser sofort versagen, als „ein bisschen zu arbeiten und am Freitagabend zu sterben“.
Wir zeigen dies anhand eines vereinfachten NotificationTemplateCatalog – einem Katalog von Benachrichtigungsvorlagen für ContextFlow. Wir machen es vorerst nicht kompliziert: Die Vorlagen werden einfach im Speicher abgelegt (ohne Ressourcen und Dateien).
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
@Component
class NotificationTemplateCatalog {
// Thread-sicherer Vorlagenspeicher, der nach der Init-Phase verfügbar ist.
private final ConcurrentHashMap<String, String> templates = new ConcurrentHashMap<>();
@PostConstruct
void init() {
// Init-Phase: Wir füllen das Verzeichnis einmal beim Start des Kontexts.
templates.put("order-created", "Order %s created");
templates.put("order-cancelled", "Order %s cancelled");
}
String template(String key) {
// Nach dem Start des Kontexts erwarten wir, dass die benötigten Schlüssel bereits geladen sind.
return templates.get(key);
}
}
Der Sinn ist: Der Konstruktor wird hier überhaupt nicht benötigt (wir haben noch keine externen Abhängigkeiten), aber die Init-Phase schon. Wir füllen das Verzeichnis einmal, und dann arbeitet es schnell und vorhersagbar.
Nun sehen wir, wie dies ein Dienst nutzt, der den Benachrichtigungstext erstellt. Wichtig: Der Dienst muss nicht wissen, wann das Verzeichnis gefüllt wird. Er erhält ein bereits „fertiges“ Objekt.
import org.springframework.stereotype.Service;
@Service
class NotificationDispatchService {
private final NotificationTemplateCatalog catalog;
NotificationDispatchService(NotificationTemplateCatalog catalog) {
// Abhängigkeit erhalten wir über DI (Constructor Injection).
this.catalog = catalog;
}
String buildCreatedText(String orderId) {
// Wir gehen davon aus, dass Init bereits den Schlüssel "order-created" gefüllt hat.
return catalog.template("order-created").formatted(orderId);
}
}
Wenn catalog.template("order-created") null zurückgibt, werden Sie den Schmerz schnell zur Laufzeit spüren. Deshalb macht man in @PostConstruct oft eine kleine Validierung, um beim Start „klar und ehrlich“ zu scheitern.
import jakarta.annotation.PostConstruct;
class CatalogValidation {
@PostConstruct
void validate() {
// Demonstration von Fail-Fast: Wenn die Initialisierung kritisch ist, stürzen wir beim Start des Kontexts ab.
throw new IllegalStateException("Example: init validation failed");
}
}
Dieses Beispiel ist absichtlich „schlecht“, um die Mechanik zu unterstreichen: Die Init-Phase ist ein normaler Ort, um festzuhalten: „Ohne dies kann mein Bean nicht arbeiten“.
Damit @PostConstruct funktioniert, muss die Annotation auf dem Classpath verfügbar sein. In unserem Kurs ist das vorgesehen, aber im echten Leben erwischt man manchmal die Situation: „Annotation gesetzt, aber die Methode wurde nicht aufgerufen“, weil die Abhängigkeit nicht hinzugefügt wurde.
Eine kleine Erinnerung im Stil von „eine Zeile, die eine Stunde spart“:
dependencies {
// Erforderlich, damit die Annotation @PostConstruct auf dem Classpath verfügbar ist.
implementation("jakarta.annotation:jakarta.annotation-api")
}
Wichtig ist für uns die praktische Tatsache: Wenn ein Bean container-managed ist, dann ist @PostConstruct der offizielle Weg, dem Container mitzuteilen: „Ich habe eine Vorbereitungsphase.“ Im Hintergrund kümmert sich die Container-Infrastruktur darum, aber für die aktuelle Aufgabe reicht der Vertrag selbst.
3. InitializingBean: Initialisierung über das Interface
Das Interface InitializingBean ist ein „oldschooliger, aber ehrlicher“ Weg. Es funktioniert in reinem Spring ohne zusätzliche Annotationen: Sie implementieren die Methode afterPropertiesSet(), und der Container ruft sie auf, nachdem die Abhängigkeiten bereits injiziert wurden. Vom Geist her ist es @PostConstruct sehr ähnlich, nur dass es statt einer Annotation ein Interface ist.
Ein Anfänger hat hier oft die Frage: „Wozu überhaupt ein Interface, wenn es eine Annotation gibt?“ Die Antwort ist einfach: Manchmal ist Ihnen eine explizite Abhängigkeit von Spring wichtiger als Neutralität. Dies kommt insbesondere bei infrastrukturellen Beans vor, bei denen niemand so tut, als sei die Klasse „framework-agnostisch“. Aber für die Geschäftsschicht und die meisten Anwendungskomponenten ist dies dennoch eine unnötige Kopplung: Die Klasse beginnt direkt vom Spring-API abzuhängen.
Machen wir ein kleines Beispiel eines Beans, das einfach „Kontext bereit“ markiert und eine Nachricht ausgibt. Das ist keine Geschäftslogik, sondern reine Diagnose/Infrastruktur – genau der Fall, bei dem eine Spring-Abhängigkeit nicht abschreckt.
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
@Component
class ContextFlowStartupMarker implements InitializingBean {
@Override
public void afterPropertiesSet() {
// Die Methode wird vom Container nach der Injektion der Abhängigkeiten aufgerufen (und vor der Verwendung des Beans).
System.out.println("ContextFlow startup marker: READY");
}
}
Die Methode heißt historisch „afterPropertiesSet“, weil Spring früher aktiv Setter-Injection verwendete. Wir bevorzugen Constructor-Injection, aber die Methode bedeutet dennoch eine klare Sache: „Nachdem der Container alles injiziert und konfiguriert hat – führe die finale Vorbereitung durch“.
Hier gibt es ein methodisches Risiko: Ein Anfänger sieht das Interface und denkt, es sei der „richtige Standard“. In der Praxis ist der Teaching Default jedoch @PostConstruct, weil es einfacher zu lesen ist und den Code weniger an Spring bindet. InitializingBean sollte man kennen, als Teil der allgemeinen Landkarte: Man trifft es in Legacy-Code und in infrastrukturellen Dingen an, und es ist wichtig, keine Angst davor zu haben.
4. initMethod in @Bean
Nun der dritte Weg – sehr praktisch. Manchmal ist die Klasse von uns, aber wir möchten, dass sie möglichst ein „normales Java-Ding“ ohne Annotationen bleibt. Manchmal ist die Klasse gar nicht von uns: eine Bibliotheksklasse, Legacy oder eine, bei der wir den Code nicht ändern dürfen. Und es kommt vor, dass ein Bean über eine @Bean-Methode erstellt wird, und es ist bequemer, den Lifecycle direkt in der Konfiguration zu beschreiben, ohne die Klasse selbst zu berühren.
Dafür gibt es den Parameter initMethod bei @Bean. Der Container ruft nach der Erstellung des Beans die Methode mit dem angegebenen Namen auf. Ja, es ist ein String (und ja, die IDE kann einen Fehler beim Umbenennen möglicherweise nicht abfangen, wenn man unachtsam ist), aber für infrastrukturelle Dinge ist das oft gerechtfertigt.
Erstellen wir einen ReportOutputManager – ein Objekt, das für die Vorbereitung des Verzeichnisses für Berichte zuständig ist. Gemäß den Projektregeln sollen Artefakte nach build/reports gehen, also bereiten wir build/reports vor.
import java.nio.file.Files;
import java.nio.file.Path;
class ReportOutputManager {
private final Path dir;
ReportOutputManager(Path dir) {
// Die Abhängigkeit (Pfad) wird von außen gesetzt – das Objekt bleibt ein einfaches POJO.
this.dir = dir;
}
void prepare() {
// Init-Logik: sicherstellen, dass das Verzeichnis existiert.
try {
Files.createDirectories(dir);
} catch (Exception e) {
// Wenn das Verzeichnis nicht erstellt werden kann, ist es besser, beim Start mit einer klaren Ursache zu scheitern.
throw new IllegalStateException(e);
}
}
Path dir() {
return dir;
}
}
Die Klasse ist absolut POJO: keine Spring-Interfaces, keine Annotationen. Nun beschreiben wir die Init-Phase in der Konfiguration.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Path;
@Configuration
class ReportingConfig {
@Bean(initMethod = "prepare")
ReportOutputManager reportOutputManager() {
// Der Container ruft prepare() nach der Erstellung des Beans selbst auf.
return new ReportOutputManager(Path.of("build/reports"));
}
}
Der Clou ist, dass prepare() vom Container aufgerufen wird. Das bedeutet, dass jeder Dienst, der einen ReportOutputManager über DI erhalten hat, annehmen kann: „Wenn der Kontext gestartet ist, existiert das Verzeichnis bereits.“ Und genau das ist die Art von Garantien, wegen derer Spring erfunden wurde: weniger manuelles Protokoll zwischen Objekten, mehr Vorhersagbarkeit.
Ein kleines Anwendungsbeispiel – selbst nur zur Demonstration, dass der Dienst nicht mehr verpflichtet ist, prepare() selbst „aufzurufen“.
import org.springframework.stereotype.Service;
@Service
class ReportingService {
private final ReportOutputManager output;
ReportingService(ReportOutputManager output) {
// Wir erhalten ein bereits „vorbereitetes“ Bean (wenn der Kontext erfolgreich gestartet ist).
this.output = output;
}
void printOutputDir() {
// Demonstration: Das Verzeichnis sollte bereit zur Nutzung sein.
System.out.println(output.dir()); // build/reports
}
}
Wichtig ist, initMethod nicht mit „ich rufe prepare() direkt innerhalb der @Bean-Methode auf“ zu verwechseln. Sie können es natürlich aufrufen, aber dann wird es zu einem manuellen Protokoll innerhalb der Konfiguration. Es ist viel ehrlicher (und einfacher zu lesen), wenn Sie dem Container explizit sagen: „Das ist ein Lifecycle-Teil, ruf es selbst auf“.
5. Wahl der Initialisierungsmethode
Nach drei Optionen möchte man fragen: „Also, welche ist die richtige?“ Und das ist eine gute Frage, denn bei einem Anfänger schaltet sich oft der Modus „ich baue ein Kombo“ ein: @PostConstruct + InitializingBean + initMethod – für alle Fälle, damit es ja funktioniert. Aber Spring ist keine Mikrowelle: „zweimal aufwärmen“ bedeutet nicht „doppelt so lecker“. Eher das Gegenteil: Sie erhalten eine unklare Aufrufreihenfolge und Unvorhersagbarkeit.
Am einfachsten wählen Sie so: Wenn es Ihre Klasse ist und die Initialisierung Teil ihrer Logik ist, nehmen Sie @PostConstruct. Wenn Sie ein infrastrukturelles Bean machen und es in Ordnung ist, dass es explizit von Spring abhängt, ist InitializingBean ebenfalls geeignet. Wenn die Klasse nicht berührt werden kann oder soll, dann initMethod in @Bean. Diese Entscheidung geht nicht um „modisch/unmodisch“, sondern um Lesbarkeit und architektonische Kosten.
Hier ist eine kompakte Tabelle, die Sie sich vor Augen halten können:
| Ansatz | Am besten geeignet | Hauptkosten |
|---|---|---|
| @PostConstruct | Ihre Klasse, Sie möchten einen lesbaren Init neben dem Code | benötigt eine Annotation auf der Klasse (und Abhängigkeit jakarta.annotation) |
| InitializingBean | infrastrukturelle Beans, Legacy, wo Spring-Coupling in Ordnung ist | die Klasse „leckt“ das Wissen über Spring |
| initMethod | @Bean-Registrierung, fremde Klasse, POJO ohne Annotationen | String-Name der Methode, Risiko von Fehlern bei Refactoring |
Und noch ein wichtiges Kriterium: Eine gute Init-Methode sollte klein und langweilig sein. Langweilig im guten Sinne. Wenn eine Init-Methode fünf Dienste aufruft, Benachrichtigungen sendet, Berichte berechnet und Netzwerkanfragen stellt, dann ist das keine „Initialisierung“ mehr, sondern ein „heimlicher Start eines Geschäftsprozesses ohne Erlaubnis“. Der Container schuldet Ihnen so etwas nicht, und die Kollegen auch nicht.
6. Reihenfolge der Init-Callback-Aufrufe
Manchmal ist es hilfreich, einmal zu sehen, was passiert, wenn man „alles mischt“. Wir machen das bewusst, wie im Labor: Im richtigen Leben sollte man so nicht schreiben, aber zum Verständnis der Aufrufreihenfolge ist es sehr anschaulich. Danach können Sie ruhig fremden Code lesen und verstehen, warum er „überflüssige Logs“ hat und „alles dreimal aufgerufen wird“.
Hier ist ein Bean, das alle Optionen gleichzeitig nutzt:
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.InitializingBean;
class InitOrderDemoBean implements InitializingBean {
InitOrderDemoBean() {
// 1) Der Konstruktor wird immer bei der Objekterstellung aufgerufen.
System.out.println("1) constructor");
}
@PostConstruct
void pc() {
// 2) Wird vom Container nach der Erstellung des Beans und der Injektion der Abhängigkeiten aufgerufen.
System.out.println("2) @PostConstruct");
}
@Override
public void afterPropertiesSet() {
// 3) Callback über das Spring-Interface InitializingBean.
System.out.println("3) afterPropertiesSet");
}
void initMethod() {
// 4) Callback, der in @Bean(initMethod = "...") angegeben ist.
System.out.println("4) initMethod");
}
}
Binden wir es über @Bean(initMethod = "initMethod") ein:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class DemoConfig {
@Bean(initMethod = "initMethod")
InitOrderDemoBean demo() {
// Wichtig: initMethod() wird vom Container aufgerufen, nicht hier manuell.
return new InitOrderDemoBean();
}
}
Und starten wir einen minimalen Kontext:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class App {
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(DemoConfig.class)) {
// Wir holen uns das Bean – zu diesem Zeitpunkt ist die Init-Phase bereits ausgeführt.
ctx.getBean(InitOrderDemoBean.class);
}
}
}
Wenn Sie das ausführen, sehen Sie in der Konsole die Reihenfolge, die man normalerweise konzeptionell im Kopf behalten sollte: Zuerst wird das Objekt erstellt (Konstruktor), dann werden die Init-Callbacks aufgerufen, und erst danach gilt das Bean als bereit.
Die wichtigste Erkenntnis aus der Demo ist einfach: Der Container kann tatsächlich all diese Dinge aufrufen, aber das bedeutet nicht, dass man die „Absicherung duplizieren“ sollte. Wählen Sie einen Weg pro Bean, und Ihr zukünftiges Ich wird Ihnen danken (und aufhören, im Tagebuch zu schreiben: „Warum geht alles morgens kaputt“).
7. Beispiele in ContextFlow
Nun kehren wir zu unserem Projekt ContextFlow zurück. Wir haben zwei Komponenten, die die Init-Phase sehr natürlich demonstrieren. Die erste ist NotificationTemplateCatalog: Sie muss einen internen Cache/Katalog von Vorlagen vorbereiten. Die zweite ist ReportOutputManager: Sie muss den Ort vorbereiten, an den die Berichte geschrieben werden.
Hier ist wichtig zu bemerken, dass wir nicht „Init um des Inits willen“ hinzufügen. Wir lösen ein praktisches Problem: Wir möchten, dass Dienste sich nicht um fremde Vorbereitung kümmern und kein Protokoll „wenn nicht bereit – bereite vor“ in sich tragen. In einer guten Architektur kümmert sich NotificationDispatchService um Benachrichtigungen, nicht um das Laden von Vorlagen; ReportingService kümmert sich um Berichte, nicht um die Erstellung von Verzeichnissen.
Daher wirken die gewählten Ansätze natürlich: Für NotificationTemplateCatalog – @PostConstruct (unsere Klasse, die Vorbereitungslogik gehört ihr), und für ReportOutputManager – initMethod (die Klasse kann POJO bleiben, der Lifecycle wird in der Konfiguration beschrieben).
Wenn Sie später die Implementierung ändern möchten, z. B. einen anderen ReportOutputManager für einen anderen Modus erstellen, bleibt das Containermodell dasselbe: „Erstelle, injiziere, bereite vor, erst dann gib es an den Use-Case weiter“. Und genau das ist das mentale Modell, das wir heute aufbauen.
8. Typische Fehler bei der Initialisierung von Beans
Fehler Nr. 1: Die Init-Phase in einen „versteckten main“ verwandeln.
Manchmal fügt ein Entwickler @PostConstruct hinzu und beginnt dort Geschäftsszenarien zu starten: „Ich generiere einen Bericht beim Start“, „ich sende allen eine Benachrichtigung, dass der Dienst gestartet ist“, „ich erstelle einen Testauftrag“. Dadurch wird die Anwendung unvorhersagbar: Schon der Start des Kontexts verursacht Seiteneffekte. Init-Logik sollte Vorbereitung und Prüfung sein, nicht Geschäftsorchestrierung.
Fehler Nr. 2: Schwere Vorbereitung im Konstruktor, „weil es einfacher ist“.
Der Konstruktor wird bei jedem new aufgerufen, auch in Unit-Tests und an Stellen, an denen Sie keine tatsächliche Vorbereitung erwarten. Wenn Sie im Konstruktor IO, Verzeichniserstellung oder Aufwärmen großer Strukturen haben, erhalten Sie ein schweres und schwer kontrollierbares Objekt. Es ist viel besser, den Konstruktor schnell zu halten und die Vorbereitung in der Init-Phase zu machen, wo der Container den Aufrufzeitpunkt steuert.
Fehler Nr. 3: @PostConstruct, InitializingBean und initMethod auf demselben Bean mischen.
Aus Sicht von Spring ist das kein „Kompilierungsfehler“, sondern ein zulässiges Verhalten: Der Container ruft alles auf, was Sie verlangt haben. Aber für eine Person, die den Code liest, ist das fast immer verwirrend. Sie erhalten nicht „mehr Zuverlässigkeit“, sondern „mehr Stellen, an denen etwas kaputtgehen kann“ und „mehr Fragen, warum das Log dreimal ausgegeben wird“.
Fehler Nr. 4: Erwarten, dass Init bei einem manuell mit new erstellten Objekt aufgerufen wird.
Das ist eine klassische Falle beim Testen oder bei temporären „manuellen“ Instanzen. Wenn Sie ein Objekt von Hand erstellen, verwaltet der Container dessen Lifecycle nicht: @PostConstruct wird nicht aufgerufen, afterPropertiesSet() wird nicht aufgerufen, initMethod wird erst recht nicht aufgerufen. Entweder starten Sie den Kontext, oder rufen Sie die Vorbereitung explizit auf, oder entwerfen Sie die Klasse so, dass sie ohne Container-Magie arbeiten kann (aber dann brauchen Sie vielleicht auch kein Init).
Fehler Nr. 5: Ausnahmen im Init „schlucken“ und so tun, als sei alles in Ordnung.
Manchmal schreibt man try/catch in der Init-Methode und gibt im catch einfach „ups“ aus. Das verwandelt ein Konfigurationsproblem in einen Laufzeitfehler, der später und an einer merkwürdigeren Stelle auftritt. Wenn die Initialisierung kritisch ist (Verzeichnis wurde nicht erstellt, erforderliche Vorlage wurde nicht geladen), ist es besser, beim Start mit einer klaren Ausnahme zu scheitern.
GO TO FULL VERSION