1. Wann ein Override des Default nötig ist
Irgendwann steht jeder Entwickler vor dem Gefühl: „Boot ist cool, aber hier hätte ich es etwas anders gemacht.“ Das ist eine normale Phase des Projekt-Wachstums. Der Dienst startet bereits, hat eine Struktur, und man beginnt Details zu sehen: wo ein anderes Nachrichtenformat, ein anderer Algorithmus, ein anderes kleines Stück Verhalten gewünscht wird.
Wichtig ist, zwei Arten von Wünschen nicht zu verwechseln. Der erste – gesund: „Ich muss einen Verhaltenspunkt ändern, weil wir einen konkreten Grund haben.“ Der zweite – gefährlich: „Mir gefällt nicht, dass ich nicht verstehe, woher das kommt, also schalten wir die Hälfte der Plattform ab und bauen alles von Hand.“ Der zweite Typ führt in der Regel dazu, dass man zwar die „Kontrolle übernimmt“, aber tatsächlich die Vorteile von Boot verliert: vorhersehbare Defaults, Kompatibilität und ordentliche Update-Unterstützung.
Halten wir sofort fest: Der sicherste Weg, Boot zu individualisieren, ist nicht das Abschalten, sondern das Ersetzen eines bestimmten Beans durch einen eigenen. Boot hat dafür eine ganze Philosophie: „Ich konfiguriere standardmäßig, aber wenn du explizit sagst ‚hier ist meine Variante‘, trete ich zur Seite.“ In englischsprachigen Materialien wird das oft als Backoff bezeichnet – „Rückzug“.
Boot kann bereits selbstständig Defaults per Auto-Configuration zusammenbauen. Nun ist es wichtig, diesen Bau nicht mit grober Hand zu zerstören. Es ist praktisch, eine einfache Eingriffsleiter im Kopf zu behalten: zuerst nach einer fertigen Property oder einem Flag suchen, dann einen einzelnen konkreten Bean austauschen, und nur wenn ein ganzer Verhaltensblock wirklich nicht benötigt wird, über Exclusion nachdenken. Und wenn man die Auto-Config-Quellen öffnen muss, ist das Ziel dasselbe: den schwächsten Hebel wählen, nicht die Plattform manuell neu zusammenbauen.
2. Default-Beans und Auto-Configuration
Wenn Sie Starter einbinden, erscheinen Bibliotheken auf dem Classpath. Und damit tauchen auch Auto-Configuration-Klassen auf – spezielle Konfigurationen, die Infrastruktur-Beans registrieren können. Aus Springs Sicht sind das ebenfalls `@Configuration`-Klassen, nur werden sie nicht per Component Scanning, sondern über den Auto-Configuration-Mechanismus eingebunden.
Stellen Sie sich vor, Spring Boot sei ein fürsorglicher Kollege, der ins Projekt kommt und sagt: „Ich sehe, dass Sie in Ihren Abhängigkeiten das und das haben. Das bedeutet, Sie brauchen wahrscheinlich solche technischen Komponenten. Ich kann sie erstellen und ‚vernünftig voreingestellt‘ konfigurieren.“ Das sind Default-Beans: keine „zwingende Wahrheit“, sondern ein Angebot der Plattform, das man annehmen oder ersetzen kann.
Um nicht durcheinanderzukommen, ist es praktisch, eine kleine Tabelle im Kopf zu haben:
| Begriff | Umgangssprachlich | Beispiel in unserem Kurs |
|---|---|---|
| Business-Bean | Ihr Anwendungscode: Domäne, Services, Repositories | CourseCatalogService, InMemoryCourseCatalogRepository |
| Infrastruktur-Bean | Technische Sache, die den Betrieb der Anwendung unterstützt | Formatierung von Startmeldungen, Konverter, Einstellungen „wie starten“ |
| Default-Bean | Infrastruktur-Bean, den Boot erstellt, „wenn du es nicht anders machst“ | „Standard-Nachrichtenformatierer“ im Übungsbeispiel |
| User-defined Bean | Ihr Bean, der explizit das gewünschte Verhalten vorgibt | Ihr Formatierer in config |
Und hier ist der Schlüssel: Boot erstellt viele Default-Beans so, dass sie sicher ersetzt werden können. Das ist kein Nebeneffekt, sondern Design. Und der Mechanismus, der dies ermöglicht, ist meist eine Anmerkung – @ConditionalOnMissingBean.
3. @ConditionalOnMissingBean und Backoff
Wenn Sie @ConditionalOnMissingBean sehen, ist es sinnvoll, dies nicht als „irgendeine bedingte Magie“ zu übersetzen, sondern als einen sehr alltäglichen Satz:
„Erstelle diesen Bean nur, wenn es in der Anwendung noch keinen Bean dieses Typs (oder mit diesem Namen) gibt.“
Das ist der Vertrag zwischen Boot und Ihrer Anwendung. Boot sagt: „Ich gebe dir eine Standardvariante, aber ich werde nicht widersprechen, wenn du deine eigene deklarierst.“ Dadurch können Sie das Verhalten der Anwendung punktuell ändern, ohne alles andere zu beschädigen.
So ungefähr sieht der Denkprozess im Kopf von Boot aus (vereinfacht, aber ehrlich):
flowchart TD
A[Boot sammelt Kontext] --> B[Zuerst werden Ihre Beans registriert]
B --> C[Dann werden Auto-Config-Klassen eingebunden]
C --> D{Wird Default-Bean X benötigt?}
D -->|"in Auto-Config steht @ConditionalOnMissingBean"| E{Gibt es bereits einen Bean vom Typ X?}
E -->|ja| F[Boot zieht sich zurück: Default wird nicht erstellt]
E -->|nein| G[Boot erstellt Default-Bean]
Beachten Sie ein wichtiges Detail: zuerst kommen die Beans der Anwendung (User-defined), dann die Auto-Configuration. Nur deshalb funktioniert das „Zurücktreten“. Wenn die Reihenfolge umgekehrt wäre, hätte Boot bereits den Default erstellt, und dann würden Sie Ihren eigenen erstellen – und Sie hätten zwei Beans desselben Typs, und dann würde Spring sagen: „Leute, wählt einen aus, ich bin kein Gedankenleser.“
Ein weiterer Punkt, der Anfänger immer wieder erwischt: meist prüft die Bedingung „Missing Bean“ den Typ, nicht den Namen. Es ist also nicht wichtig, dass die Methode startupMessageFormatter() heißt – wichtig ist der Rückgabetyp StartupMessageFormatter. Wenn Sie den Default ersetzen wollen, müssen Sie einen Bean desselben Sinns und desselben Typs deklarieren, nicht einen „ähnlichen nach Namen“.
4. Praxis: Default + Override im catalog-service
Jetzt bauen wir ein kleines, aber sehr anschauliches Beispiel direkt im Kontext des catalog-service. Wir werden nicht in den echten Boot-Code eintauchen (das ist kein Kurs „Wir schreiben Spring Boot“), aber wir machen eine lehrreiche Infrastruktur-Sache, die sich genauso verhält wie echte Auto-Config-Defaults.
Die Idee: Wir haben einen StartupSummaryRunner, der beim Start etwas ausgibt. Wir möchten, dass das Nachrichtenformat geändert werden kann, ohne den Runner umzuschreiben. Dazu führen wir eine Interface-Abstraktion StartupMessageFormatter ein, geben ihm eine Default-Implementierung „von der Plattform“ und zeigen dann, wie die Anwendung ihren eigenen Formatierer vorgibt und der Boot-style Default „zurücktritt“.
Vertrag: Interface für den Anwendungscode
Wir beginnen mit dem Wichtigsten: dem Vertrag. Das ist ein kleines Interface, von dem unser StartupSummaryRunner abhängen wird.
package com.example.catalogservice.support.startup;
public interface StartupMessageFormatter {
/**
* Formatiert die Nachricht für die Ausgabe beim Start der Anwendung.
* Hier gibt es bewusst keine Spring-Annotationen: es ist ein reiner Vertrag.
*/
String format(String appName);
}
Beachten Sie: Hier gibt es überhaupt kein Spring. Das ist reines Java. Und das ist gut: Je weniger „Framework-Geschmack“ in den Domänen-/Anwendungsverträcken steckt, desto einfacher sind sie zu verstehen und zu testen.
Default von der Plattform: Bean nur, wenn es keinen eigenen gibt
Jetzt erstellen wir eine Konfiguration, die sich wie eine „von der Plattform bereitgestellte Default“ verhält. Im echten Spring Boot leben solche Dinge in Auto-Config-Klassen. Wir machen eine sehr kleine Version, um das Muster zu sehen.
package com.example.catalogservice.support.startup;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
@AutoConfiguration // Dies ist eine Auto-Configuration, keine normale @Configuration "per Scan"
public class StartupSupportAutoConfiguration {
@Bean
@ConditionalOnMissingBean // Default wird nur erstellt, wenn der Benutzer KEINEN eigenen Bean desselben Typs deklariert hat
StartupMessageFormatter startupMessageFormatter() {
return (String appName) -> {
// Standardformat – maximal einfach und vorhersagbar
return "Started: " + appName;
};
}
}
Hier ist alles, weswegen wir uns versammelt haben:
- @AutoConfiguration – bedeutet „dies ist eine Konfiguration, die Boot als Teil des Auto-Configuration-Mechanismus einbinden kann“.
- @ConditionalOnMissingBean – bedeutet „Erstelle den Default nur, wenn der Benutzer noch keinen eigenen erstellt hat“.
Wir haben die Implementierung bewusst als Lambda gemacht, um keine Klassen zu vervielfachen. Uns interessiert jetzt das Verhalten, nicht die Schönheit der Architektur.
Einbinden der Übungs-Auto-Configuration über eine Imports-Datei
Damit Boot unsere Übungs-Auto-Configuration überhaupt als Auto-Configuration-Kandidaten sieht, benötigt er eine Imports-Datei. Das ist kein Component Scan, sondern ein separater Mechanismus. Betrachten Sie es vorerst als „dienstliche Liste von Konfigurationen, die Boot automatisch einbinden kann“.
Datei: src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.catalogservice.support.startup.StartupSupportAutoConfiguration
Ja, das ist buchstäblich eine Liste von Klassen, zeilenweise. Keine lateinischen Zaubersprüche – alles recht geradlinig (was diejenigen überrascht, die Magie erwarten).
Anwendungscode: StartupSummaryRunner hängt vom Interface ab
Jetzt ändern wir unseren Runner so, dass er nicht von „einer konkreten Klasse“, sondern vom Interface abhängt. Das ist der Moment, der den Override sicher macht.
package com.example.catalogservice.catalog.bootstrap;
import com.example.catalogservice.support.startup.StartupMessageFormatter;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner implements ApplicationRunner {
// Wir injizieren genau den Vertrag, nicht eine konkrete Implementierung:
// so lassen wir uns die Möglichkeit, die Implementierung sicher zu ersetzen, ohne den Runner umzuschreiben.
private final StartupMessageFormatter formatter;
public StartupSummaryRunner(StartupMessageFormatter formatter) {
this.formatter = formatter;
}
@Override
public void run(ApplicationArguments args) {
// Beobachtbares Verhalten: wir geben eine Zeile aus, um zu sehen, welcher Formatierer tatsächlich verwendet wird.
System.out.println(formatter.format("catalog-service")); // Started: catalog-service
}
}
Der Sinn ist, dass StartupSummaryRunner jetzt nicht weiß (und nicht wissen muss), wer genau die Nachricht formatiert. Er kennt nur den Vertrag. Das ist wie eine Steckdose: Es ist Ihnen egal, welches Kraftwerk in der Stadt ist – Hauptsache, die Spannung „passt“.
Wenn Sie die Anwendung zu diesem Zeitpunkt starten, sehen Sie die Standardmeldung:
Started: catalog-service
Eigener Bean: Boot tritt zurück, Default wird nicht erstellt
Jetzt machen wir das, weswegen alles begonnen wurde: Wir deklarieren unseren eigenen Bean vom selben Typ StartupMessageFormatter. Und legen ihn dorthin, wo er architektonisch hingehört – in das Paket config, damit Anpassungen nicht „irgendwo zufällig“ versteckt sind.
package com.example.catalogservice.config;
import com.example.catalogservice.support.startup.StartupMessageFormatter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StartupConfiguration {
@Bean
StartupMessageFormatter startupMessageFormatter() {
// Dieser Bean hat denselben Typ wie der Default, daher wird die Bedingung
// @ConditionalOnMissingBean "fehlschlagen", und der Default aus der Auto-Config wird nicht erstellt.
return (String appName) -> {
// Hier kann jede beliebige Formatierungslogik stehen: Präfixe, Umgebung, Version usw.
return "[catalog-service] " + appName + " is ready";
};
}
}
Jetzt sagt StartupSupportAutoConfiguration beim Start: „Aha, StartupMessageFormatter existiert bereits, also brauche ich meinen Default nicht“. Und Ihr Runner gibt etwas anderes aus:
System.out.println(formatter.format("catalog-service"));
// [catalog-service] catalog-service is ready
Sie haben also einen einzigen Verhaltenspunkt geändert und den Rest der Boot-Welt nicht angefasst. Das ist „gesunde Individualisierung“.
5. Interface als Sicherung für die Individualisierung
Es ist sehr leicht, in die Falle zu tappen: „Wozu brauche ich ein Interface, wenn ich eine konkrete Implementierung habe?“ Die Falle schnappt bei Anfängern besonders schnell zu, weil Interfaces oft als „Bürokratie um der Bürokratie willen“ wahrgenommen werden. Aber in Boot-Projekten ist ein Interface kein Schmuck, sondern eine Möglichkeit, den Austausch von Verhalten sicher und billig zu machen.
Stellen Sie sich vor, StartupSummaryRunner würde direkt eine konkrete Klasse injizieren, z.B. DefaultStartupMessageFormatter. Dann müssten Sie, um das Verhalten zu ändern, entweder den Code des Runners ändern (also die Anwendungsschicht für eine Infrastrukturänderung anfassen) oder versuchen, den Container durch @Primary, @Qualifier, Umbenennungen und andere Tänze zu „überlisten“. Das ist keine „punktuelle Anpassung“ mehr, sondern eine Miniquest.
Wenn der Runner dagegen ein Interface injiziert, erhalten Sie ein sauberes Bild: Es gibt einen Vertrag, eine Standardvariante von der Plattform und Ihre Variante. Sie können die Implementierung ersetzen, ohne eine einzige Zeile im Konsumenten zu ändern. In großen Systemen spart das nicht Minuten – sondern Tage, weil Änderungen lokal und vorhersagbar werden.
Und ein weiterer kleiner, aber wichtiger Pluspunkt: Das Interface erlaubt es Ihnen, gedanklich zu trennen, „was benötigt wird“ von „wie es gemacht wird“. Das ist auch ohne Spring nützlich. Spring macht diesen Stil nur besonders angenehm, weil das Wiring der Container übernimmt.
6. Überprüfen, ob der Override funktioniert hat
Wenn Sie das Verhalten durch Ihren eigenen Bean überschreiben, ist es wichtig, ehrlich auf die Frage antworten zu können: „Habe ich den Default wirklich ersetzt, oder habe ich versehentlich einen zweiten Bean erzeugt und alles hält nur durch Glück?“ Auf Junior-Niveau ist das besonders wichtig, weil die ersten Probleme meist so aussehen: „Warum hat es gestern funktioniert und heute nicht?“
Der einfachste Weg zur Überprüfung in einem Übungsprojekt ist, das Verhalten beobachtbar zu machen. Das haben wir bereits durch die Ausgabe der Zeile getan: man sieht, welcher Formatierer verwendet wird. Manchmal ist es hilfreich, vorübergehend auch die Implementierungsklasse auszugeben, um nicht zu raten:
System.out.println(formatter.getClass().getName());
// com.example.catalogservice.config.StartupConfiguration$$Lambda...
Ja, der Name wird seltsam sein (Lambdas tarnen sich gerne), aber die Idee ist klar: Sie erhalten einen Beweis, nicht nur ein Gefühl.
Ein etwas „ingenieurmäßigerer“ Ansatz ist es, die Anpassungen an einem sichtbaren Ort zu halten. Wenn Ihr Bean in config liegt, wissen Sie immer, wo Sie die Antwort auf die Frage „Warum hat sich das Format geändert?“ suchen müssen. Wenn Sie den Bean dagegen zufällig in einem @Component irgendwo tief in Paketen versteckt deklarieren, schaffen Sie sich selbst eine Ermittlung für den Freitagabend.
Und zum Schluss: Wenn Sie einen Override machen, versuchen Sie, ihn in Worten so zu formulieren, dass es wie eine normale Anforderung klingt, nicht wie Schamanismus. Zum Beispiel: „Wir haben StartupMessageFormatter durch unsere Variante ersetzt, daher wird der Default-Bean aus der Auto-Config nicht mehr erstellt.“ Das klingt langweilig – und langweilig bedeutet vorhersagbar. In der Infrastruktur ist „langweilig“ meist ein Kompliment.
7. Typische Fehler beim Überschreiben von Default-Beans
Fehler Nr. 1: Man erstellt einen „eigenen Bean“, aber von einem anderen Typ – und wundert sich, dass der Default nicht verschwindet.
Das ist die häufigste Falle. @ConditionalOnMissingBean prüft normalerweise den Typ. Wenn der Default für StartupMessageFormatter erstellt wird, Sie aber einen Bean eines anderen Vertrags deklarieren oder einen Typ zurückgeben, den die Bedingung nicht prüft, bleibt der Default bestehen. Spring schaut zuerst auf den Typ, nicht auf den schönen Methodennamen. Das lässt sich einfach beheben: Überprüfen Sie immer, welchen Typ Ihre @Bean-Methode zurückgibt und welcher Typ in der Bedingung steht.
Fehler Nr. 2: Man injiziert eine konkrete Implementierung und versucht dann, den „Default zu überschreiben“.
Solange Sie ein Interface injizieren, ist die Ersetzung eine Ersetzung. Sobald Sie eine konkrete Klasse injizieren, verbauen Sie sich selbst die Möglichkeit eines sanften Override. In der Folge müssen Sie entweder den Code des Konsumenten umschreiben oder ein kompliziertes Wiring mit Qualifiern veranstalten. In einem Übungsprojekt sieht das so aus: „Ich wollte eine Zeile ändern, aber bekam drei Klassen und fünf Annotationen.“
Fehler Nr. 3: Man versteckt den benutzerdefinierten Bean an einer zufälligen Stelle im Projekt.
Manchmal macht ein Student eine @Component-Klasse irgendwo in catalog.service und denkt: „Na ja, es ist ein Bean, also ist alles in Ordnung.“ Formal ja, aber architektonisch haben Sie die Geschäftsschicht und die Infrastrukturanpassung vermischt. In einer Woche vergessen Sie, warum der Dienst plötzlich anders antwortet, und beginnen, einen „Bug“ zu suchen. Richtiger ist es, solche Dinge im Paket config zu halten und die Konfiguration sinnvoll zu benennen.
Fehler Nr. 4: Man versucht, den „Default zu ersetzen“, indem man ein großes Stück Konfiguration kopiert.
Es ist sehr verlockend: im Internet ein Beispiel finden, eine ganze Auto-Config-Klasse kopieren und „ein paar Zeilen anpassen“. Das Problem ist, dass Sie eine riesige Menge an Nebeneinstellungen ins Projekt holen, die Sie nicht verstehen. Heute funktioniert alles, morgen beim Update einer Abhängigkeit – Überraschung. Viel sicherer ist es, mit einem engen Override eines einzelnen Beans zu beginnen, der die gewünschte Verhaltensstelle wirklich beeinflusst.
Fehler Nr. 5: Man kann nicht erklären, was sich im Kontext geändert hat.
Wenn Sie nach einem Override nicht kurz sagen können, „welcher Bean jetzt verwendet wird und warum der Default nicht erstellt wurde“, bedeutet das fast immer, dass die Lösung noch nicht im Kopf gereift ist. Und alles, was im Kopf nicht gereift ist, wird in der Produktion meist als Vorfall sichtbar. Eine gute Gewohnheit ist es, auszusprechen: „Wir haben unseren Bean vom Typ X deklariert, daher @ConditionalOnMissingBean wurde nicht erfüllt und der Default nicht registriert.“ Das klingt wie ein langweiliger Bericht – und genau deshalb funktioniert es.
GO TO FULL VERSION