1. Einleitung
Wenn eine Anwendung startet, ist das schön. Aber Spring-Projekte haben einen hinterhältigen Nebeneffekt: Sie starten oft, auch wenn Sie nicht vollständig verstehen, warum genau alles funktioniert. Im Kopf bleibt dann „Magie“: Annotationen, irgendwelche Konfigurationen, irgendwo Events, irgendwo Aspekte … und das alles wirkt, als ob es nicht ein System, sondern verschiedene Demo-Teile wären. Die endgültige Montage ist nötig, damit Sie ein vollständiges mentales Modell haben: ein Weg, eine Logik, ein ApplicationContext.
In dieser Vorlesung halten wir uns an eine einfache Achse: main() → Kontexterstellung → Konfigurationsregistrierung → Lesen von Umgebung/Profilen/Eigenschaften → Bean-Erstellung → Szenariostart. Alles andere (Ressourcen, Nachrichten, Events, Post-Processors, AOP, XML-Zweig, Tests) werden wir nicht aufzählen, sondern sorgfältig in diesen Weg „einbetten“, wie Details in einen gemeinsamen Mechanismus.
Um nicht den Überblick zu verlieren, hier ein kompaktes Diagramm „was mit was zusammenhängt“:
flowchart TD A["main()"] --> B["ApplicationContext"] B --> C["Konfigurationsmodule @Import"] B --> D["Umgebung: Profile + Eigenschaften"] B --> E["Bean-Definitionen -> Beans"] E --> F["ScenarioRunner.run()"] F --> G["Anwendungsfalldienste"] G --> H["publishEvent()"] H --> I["@EventListener Nebenwirkungen"] E --> J["Support: BFPP/BPP/FactoryBean/AOP"] C --> K["Legacy XML-Brücke"] B --> L["Spring Test-Kontext"]
Der Sinn des Diagramms ist nicht, die Pfeile zu lernen. Der Sinn ist, dass es ein System ist, in dem jeder Mechanismus einen „Einstiegspunkt“ in den gemeinsamen Ablauf hat.
2. Einstiegspunkt: main() und ApplicationContext
Ein normaler Anwendungsstart ist der Moment, in dem Sie sehen sollten, wer hier der Chef ist. Und in unserem Kurs ist der Chef nicht die „Annotation des Tages“, sondern der ApplicationContext. In Spring Boot scheint es oft, dass die Anwendung „von selbst“ startet (weil SpringApplication.run() viel Arbeit für Sie erledigt). Aber heute bleiben wir bewusst im reinen Spring Core: Sie erstellen manuell den Kontext, wählen Profile, registrieren die Konfiguration und rufen refresh() auf. Das ist keine „unnötige Routine“, sondern eine lehrreiche Röntgenaufnahme.
Ein minimaler, ehrlicher Einstiegspunkt der endgültigen Version von ContextFlow könnte so aussehen:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowMain {
public static void main(String[] args) {
// Den Kontext besser explizit schließen: sonst könnten @PreDestroy/destroyMethod nicht ausgeführt werden.
try (var context = new AnnotationConfigApplicationContext()) {
// Das Profil ist wichtig VOR refresh(): es beeinflusst, welche Beans überhaupt registriert werden.
context.getEnvironment().setActiveProfiles("demo");
// Registrieren der Wurzelkonfiguration der Anwendung (Composition Root).
context.register(AppConfig.class);
// Starten der Montagephase: Einlesen von Konfigurationen, Erstellen von Bean-Definitionen, Instanziierung von Beans usw.
context.refresh();
// Der Szenariostart ist die Startgrenze, hier ist manuelles getBean() zulässig.
context.getBean(ScenarioRunner.class).run();
}
}
}
Beachten Sie zwei Dinge.
Erstens – das Profil wird vor refresh() gesetzt. Das ist wichtig, weil Profile beeinflussen, welche Beans überhaupt registriert und erstellt werden. Setzen Sie das Profil nach refresh(), erhalten Sie den klassischen Effekt „Ich habe den Modus umgeschaltet, aber nichts hat sich geändert“ (und Sie werden anfangen, eine Verschwörung zu vermuten, obwohl Sie selbst schuld sind).
Zweitens – try-with-resources. Das Schließen des Kontexts ist keine „Zeremonie“. Es ist der Grund, warum @PreDestroy und destroyMethod tatsächlich ausgeführt werden und Ressourcen (Dateien, Streams, Ausgabemanager) ordnungsgemäß geschlossen werden. Spring kann den Kontext nicht durch „Gedankenkraft“ schließen, wenn Sie ihn nicht schließen.
Und ja: Sie sehen hier ein manuelles getBean(), aber dies ist nur an der Startgrenze, so wie Sie es zuvor mit dem Composition Root in Plain Java gemacht haben. Die Geschäftsschicht sollte sich nicht in einen „Professor getBean“ verwandeln.
3. AppConfig: modulare Konfiguration statt riesigem Haufen
Wenn das Projekt klein ist, scheint eine einzelne Konfigurationsklasse normal. Aber sobald Sie Profile, Ressourcen, Nachrichten, Reporting, einen Legacy-XML-Zweig und eine Support-Schicht hinzufügen, wird „ein AppConfig für alles“ zur textuellen Version einer Garage, in der Sie Fahrrad, Kühlschrank und alte Diplome aufbewahren. Modulare Konfiguration ist nicht der Schönheit wegen nötig, sondern der Lesbarkeit: Sie öffnen AppConfig und sehen, aus welchen Teilen die Anwendung besteht.
In der endgültigen Montage ist es praktisch, wenn das oberste Config fast leer ist – es „setzt die Module zusammen“:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import({
// Basis der Anwendung: Domänendienste/Ports/Repositories usw.
CoreConfig.class,
// Profile (dev/demo/test) und Verhaltensvariabilität.
ProfilesConfig.class,
// Berichte, Formatierung und Ausgabe.
ReportingConfig.class,
// Brücke zum Legacy-XML-Fragment.
LegacyBridgeConfig.class,
// Infrastrukturerweiterungen von Spring: BFPP/BPP/FactoryBean/AOP und Diagnose.
SupportConfig.class
})
public class AppConfig {
}
Jetzt ein wichtiger Gedanke: @Import ist nicht „eine weitere Registrierungsmethode“. Es ist eine Möglichkeit, das Wiring lesbar zu machen. Ihre Module sind nicht „Architekturschichten für ein Diagramm“, sondern praktische Navigation im Projekt.
Zum Beispiel ist CoreConfig für die grundlegende Zusammenstellung der Domänenports und -dienste zuständig, ProfilesConfig für die Variabilität des Modus (dev/demo/test), ReportingConfig für Berichte und Ausgabe, LegacyBridgeConfig für den Anschluss des XML-Fragments, und SupportConfig für alles, was mit Post-Processors, Factory Beans, Diagnose und AOP zu tun hat.
Wenn Sie überprüfen möchten, ob die Modularität funktioniert, stellen Sie sich die Frage: „Wenn morgen das Audit in Dateien ausfällt, in welchem Modul suche ich danach?“ Bei einer guten modularen Konfiguration wird die Antwort fast automatisch sein.
4. Umgebung: Profile, Properties und Conversion
Das Praktische an Spring ist, dass die Geschäftslogik „sauber“ bleiben kann, während sich das Verhalten der Anwendung durch Konfiguration ändert. Aber das hat eine Bedingung: Die Konfiguration muss früher geladen werden, als Sie das Szenario starten. Das heißt, Ihr ApplicationContext erstellt zuerst die Umgebung und die benötigten Beans, und dann führen Sie den Anwendungsfall aus. Das ist so, als ob Sie zuerst die Küche vorbereiten (Zutaten holen, den Herd einschalten) und dann das Omelett braten, anstatt zu versuchen, den Herd mitten im Braten einzuschalten.
In ContextFlow halten wir uns an ein einfaches Modell: Es gibt eine Basis-contextflow.properties sowie profil-spezifische Varianten, z.B. contextflow-demo.properties und contextflow-test.properties. Es ist wichtig, dies nicht mit Boot-Konventionen zu verwechseln: In reinem Spring werden solche profil-spezifischen Quellen explizit über @PropertySource oder programmatische Registrierung eingebunden, eine bloße Namensübereinstimmung mit dem Profil reicht dem Container nicht aus. Dann lesen Sie sie über @Value oder Environment aus.
Angenommen, wir möchten das Berichtsformat und das Ausgabeverzeichnis steuern:
# contextflow.properties
contextflow.report.format=text
contextflow.report.output-dir=build/contextflow/reports
Und dann in der Konfiguration dies in die Infrastruktur „einbetten“ (vereinfacht):
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReportingConfig {
@Bean
ReportOutputManager reportOutputManager(
// Der Wert wird vom Container bei der Bean-Erstellung eingesetzt.
@Value("${contextflow.report.output-dir}") String outputDir) {
// Hier bewahren wir bewusst nur die Infrastruktur (wohin schreiben), nicht die Geschäftslogik.
return new ReportOutputManager(outputDir);
}
}
Hier ein wichtiger Punkt für das mentale Modell: @Value ist keine „Zeichenkettenmagie“. Es ist Teil des Mechanismus, den der Container beim Start ausführt, bevor Sie das Szenario starten. Wenn der Wert fehlt, erfahren Sie das entweder beim Start oder beim Erstellen des spezifischen Beans (je nach Lazy/Eager Creation). Das ist der Fail-Fast-Ansatz: besser sofort zu scheitern als „mitten in der Auftragsabwicklung“.
Und wo bleibt Conversion? In der endgültigen Version möchten Sie nicht if ("text".equals(format)) ... im Geschäftsservice schreiben. Daher wird das Berichtsformat bei Ihnen z.B. zu einem Enum, und der String aus Properties wird über den ConversionService und Ihren Converter in einen Typ umgewandelt. Dieselbe Logik: All dies wird vom Container vor der Ausführung des Szenarios vorbereitet.
5. Resource und MessageSource
Wenn Sie zum ersten Mal „Ressourcen“ und „Nachrichten“ hören, neigen Sie vielleicht zu der Annahme: „Nun, das ist ja für UI und Web.“ Aber in einer Non-Web-Anwendung ist es sogar einfacher und ehrlicher: Benachrichtigungsvorlagen und Berichtskopfzeilen sind externe Artefakte, die neben dem Code leben, versioniert werden und vorhersagbar geladen werden müssen. Und lokalisierte Texte sind eine Möglichkeit, Strings nicht in Java-Code einzubetten, besonders wenn sie sowohl in Benachrichtigungen als auch in Berichten und in der Konsolenausgabe verwendet werden.
Stellen Sie sich vor, Sie haben eine Benachrichtigungsvorlage zur Bestellerstellung:
src/main/resources/templates/notifications/order-created.txt
Und einen Vorlagenkatalog, der beim Start geladen wird (die Initialisierung „lebt“ im Lifecycle, nicht im ersten Aufruf der Geschäftsmethode). Ein stark vereinfachtes Beispiel:
import jakarta.annotation.PostConstruct;
import org.springframework.core.io.Resource;
public class NotificationTemplateCatalog {
// Resource ist nicht "java.io.File": es ist eine Spring-Abstraktion, die sowohl mit Classpath als auch mit URL usw. arbeiten kann.
private final Resource orderCreatedTemplate;
public NotificationTemplateCatalog(Resource orderCreatedTemplate) {
// Die Abhängigkeit kommt genauso vom Container wie jede andere.
this.orderCreatedTemplate = orderCreatedTemplate;
}
@PostConstruct
void init() {
// Diese Phase wird vom Container nach der Bean-Erstellung aufgerufen: praktisch für schnelle Überprüfungen und Vorbereitung.
System.out.println("Templates ready: " + orderCreatedTemplate.exists()); // Templates ready: true
}
}
Wichtig ist nicht, dass wir true ausgeben. Wichtig ist, dass Sie den Platz des Mechanismus im Gesamtbild sehen: Die Ressource ist nicht „einfach eine Datei“, sondern eine Abhängigkeit, die der Container einem Bean geben kann, und das Bean kann sie in der Init-Phase überprüfen und vorbereiten.
Mit MessageSource ist die Geschichte ähnlich. Sie binden Bundles wie messages.properties, messages_ru.properties, messages_en.properties ein und erhalten dann die Nachricht nach Schlüssel und Locale. Und auch dies ist Teil der Gesamtmontage: Der Container stellt MessageSource als Infrastruktur bereit, und die Geschäfts-/Infrastrukturschicht verwendet sie für Texte.
6. Anwendungsfall: Szenario → Service → Event, Side Effects in Listener
Wenn das Projekt wächst, ist die unangenehmste Degradation, wenn der „Haupt“-Anwendungsfall-Service zu einer Kombimaschine wird: Er speichert die Bestellung, berechnet den Rabatt, schreibt das Audit, sendet Benachrichtigungen, aktualisiert Statistiken und erstellt noch irgendwo in der Ecke einen Bericht „für alle Fälle“. Ein solcher Service funktioniert normalerweise, aber er ist schwer zu lesen: Jede Änderung wird zu einer Mini-Operation am offenen Herzen.
Events in unserem Projekt lösen genau das: Der Anwendungsfall führt die Hauptaktion aus und veröffentlicht den Fakt, während die Nebeneffekte von separaten Listenern ausgeführt werden.
Miniversion von OrderPlacementService:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(String orderId) {
// Der Service macht das „Wesentliche“: veröffentlicht den Geschäftsfakt, anstatt Audit/Benachrichtigungen/Statistiken mitzuschleppen.
publisher.publishEvent(new OrderCreatedEvent(orderId));
// Wichtig zu merken: standardmäßig ist das synchron (Listener werden im aktuellen Thread ausgeführt).
}
}
Audit als Listener (vereinfacht):
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Side Effect lebt separat: Der Anwendungsfall weiß nicht, dass ihn jemand auditiert.
System.out.println("AUDIT orderId=" + event.orderId()); // AUDIT orderId=...
}
}
An dieser Stelle ist es nützlich, sich an Ihr mentales Modell aus den vorherigen Tagen zu erinnern: Events sind standardmäßig synchron. Das heißt, publishEvent() „stellt nicht in eine Warteschlange“, sondern ruft die Listener direkt im aktuellen Thread auf. Das bedeutet, dass der Anwendungsfall erst dann „abgeschlossen“ ist, wenn alle Handler fertig sind. Und das ist normal für unsere Lehr-Non-Web-Anwendung.
Aus Sicht der endgültigen Montage ist etwas anderes wichtig: Der Anwendungsfall-Service weiß nicht, wer das Event abhört. Er weiß nur, dass er den Fakt „Bestellung erstellt“ veröffentlicht. Das ist die lose Kopplung, die das System weniger verbunden macht.
7. Support-Schicht: BFPP, BPP, FactoryBean und AOP
Die inneren Mechanismen von Spring können leicht zur schlechten Angewohnheit werden: Man möchte überall Aware hinzufügen, irgendwo eine Factory bauen, irgendwo einen Post-Processor „nachjustieren“, und plötzlich ähnelt der Geschäftscode eher der Spring-Dokumentation als der Geschäftslogik. Deshalb gibt es in unserem Projekt eine separate support.*-Schicht: Dort leben Post-Processors, FactoryBean, diagnostische Aware-Beans und AOP. Das ist nicht „eine weitere Schicht um der Schichten willen“, sondern eine Sanitärzone: Spring-Spezifika sind isoliert.
Beginnen wir mit zwei Extension Points, die den Start beeinflussen.
BeanFactoryPostProcessor lebt in der Phase „Metadaten sind noch nicht zu Objekten geworden“. Es ist ideal für einen Sanity Check: Prüfen, dass wichtige Properties existieren, keine kritischen Widersprüche, obligatorische Bean-Definitionen vorhanden. Ein beispielhafter (stark vereinfachter) Code:
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
public class ContextFlowSanityCheckBeanFactoryPostProcessor
implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
// Dieser Punkt wird VOR der Bean-Erstellung ausgeführt: wir arbeiten mit Bean-Definitionen, nicht mit Objekten.
System.out.println("Sanity check: beanCount=" + bf.getBeanDefinitionCount());
}
}
BeanPostProcessor arbeitet bereits mit Objekten (vor/nach init). Dort kann man bequem Diagnose, „Tracking“ von Komponenten und leichte Umhüllung durchführen. Zum Beispiel die Initialisierung bestimmter Beans loggen, ohne in jede Klasse einzusteigen. Das ist die „Infrastrukturmagie“, die die Geschäftsschicht nicht verdirbt.
Dann FactoryBean. Wir verwenden es genau einmal und nur dort, wo es den Container wirklich erklärt: ReportFormatterFactoryBean wählt die Implementierung des Formatters basierend auf der Konfiguration. Wichtig ist, dass der „Formatter“ ein Produkt der Factory ist und die Factory selbst ebenfalls ein Bean. Und das kann man sehen, wenn man sich an den Trick &beanName erinnert, der Zugriff auf die Factory selbst gibt.
Dann diagnostisches Aware-Bean. Es kann seinen Namen, die Environment, den Kontext erhalten – aber nur als isolierte Komponente, damit Geschäftsservices nicht manuell nach Beans suchen. Wenn ein Geschäftsservice ApplicationContext erhält, ist das fast immer ein Smell: Sie sind von DI zum Service Locator gewechselt.
Und schließlich AOP. Der Aspekt ServiceTimingAspect fügt technisches Verhalten um die Methoden der Service-Schicht herum hinzu (z.B. Zeitmessung). Und hier hilft die endgültige Montage besonders: Sie beginnen zu sehen, dass der Aspekt keine „Compilermagie“ ist, sondern das Ergebnis eines proxybasierten Modells, das der Container über Infrastrukturmechanismen aufbaut.
8. Legacy XML: isolierte Brücke
XML in modernen Projekten ist nicht die Norm, aber es ist eine Realität, die in Legacy-Systemen vorkommt. Und das Wichtigste, was Sie aus diesem Kursteil mitnehmen sollten: XML ist derselbe Spring, nur eine andere Syntax zur Beschreibung des Bean-Modells. Es ist keine „separate Technologie“ und schon gar keine „dunkle Magie“. Wenn Sie Bean-Definitionen, Scopes, Wiring und Lifecycle verstehen, dann liest sich XML fast wie „Annotationen, nur als Text“.
In der endgültigen Version von ContextFlow wird das XML-Fragment als Bridge eingebunden, z.B. über @ImportResource in einem separaten Modul:
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
// Brücke zum Legacy: separates Modul, damit XML nicht über das gesamte Projekt „verschmiert“ wird.
@ImportResource("classpath:legacy/legacy-notification-context.xml")
public class LegacyBridgeConfig {
}
Und im Inneren der XML-Datei sehen Sie vertraute Dinge – bean id, class und ggf. Abhängigkeiten:
<!-- Gewöhnliche Bean-Definition: dasselbe Bean-Modell, nur in einer anderen Syntax. -->
<bean id="consoleAuditWriter"
class="com.example.contextflow.infrastructure.audit.ConsoleAuditWriter"/>
Der Clou der endgültigen Montage ist, dass der XML-Zweig das Projekt nicht verwässert: Er ist isoliert. Sie machen die Anwendung nicht zu XML-first, sondern können einfach mit einem Stück Legacy leben, bis es migriert ist.
9. Tests: Überprüfen der Montage, statt „daran zu glauben“
Das Testen der Spring-Konfiguration geht nicht darum, „alles mit Tests abzudecken“, sondern darum, keine Angst mehr zu haben, dass Sie versehentlich das Wiring, Profile, Property-Overrides, Listener oder die Legacy-Brücke kaputt gemacht haben. Idealerweise sollten die Tests denselben AppConfig verwenden wie der reale Start, sonst testen Sie ein „anderes Universum“ und wundern sich, warum es in der Produktion nicht so ist.
Ein minimaler Smoke Test kann sehr klein sein – manchmal reicht es, dass der Kontext hochkommt:
import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(AppConfig.class)
@ActiveProfiles("test")
class ContextSmokeTest {
@Test
void contextStarts() {
// Wenn der Kontext nicht startet, schlägt der Test von selbst fehl (beim Hochfahren des TestContexts).
}
}
Das sieht fast wie ein „leerer Test“ aus, aber er prüft etwas sehr Wichtiges: Wiring, Profile, Property-Quellen, Konvertierung, Listener-Registrierung, Legacy-XML-Unterstützung, AOP-Unterstützung und das Vorhandensein Ihrer Support-Beans. Das heißt, er prüft genau das, was mit reinen Unit-Tests nicht überprüft werden kann.
Und beachten Sie erneut die gemeinsame Linie: Der Test ist einfach ein anderer Einstiegspunkt in denselben ApplicationContext. Im endgültigen mentalen Modell sind „Anwendungsstart“ und „Kontextstart im Test“ nicht verschiedene Welten, sondern ein Mechanismus in verschiedenen Modi.
10. Typische Fehler der endgültigen Montage
Fehler Nr.1: Das Projekt durch eine Liste von Annotationen erklären.
Wenn sich das Finale auf „wir haben @Configuration, @Profile, @EventListener und @Aspect“ reduziert, entsteht beim Studenten wieder das Gefühl von Magie. Annotationen beginnen ein Eigenleben ohne Verbindung zur Programmausführung. Wichtig ist, den Fokus auf den Ablauf zu halten: main → ApplicationContext → Bean-Erstellung → Szenariostart. Annotationen sind nur eine Möglichkeit, diesen Weg zu beschreiben, nicht sein Ersatz.
Fehler Nr.2: Den Gesamtzusammenhang durch kleine Details verlieren.
Es ist leicht, sich in der Diskussion „wo liegt die Vorlage“ oder „wie wird die Ressource geladen“ zu verlieren und zu vergessen, dass dies Einzelfälle sind. Wenn der allgemeine Montagemechanismus nicht klar ist, retten Details nicht – sie überlasten nur. Zuerst muss das Gesamtbild klar sein: Wie wird der Container zusammengestellt, wie werden Beans verbunden, wie wird das Szenario gestartet.
Fehler Nr.3: Orchestrierung und Side Effects vermischen.
Wenn der Anwendungsfall-Service beginnt, sowohl das Szenario zu steuern als auch zu loggen, Events zu senden und Audits durchzuführen, wächst er und verliert an Lesbarkeit. Events werden zur Formalität und Listener zur Duplizierung. Die richtige Struktur: Der Service beschreibt das Szenario, und die Nebeneffekte werden in separate Handler ausgelagert.
Fehler Nr.4: XML als „anderen Spring“ behandeln.
XML wird oft als etwas Fremdes und Veraltetes wahrgenommen, weshalb man es vermeidet oder fürchtet. In Wirklichkeit ist es derselbe Container, nur in einer anderen Syntax beschrieben. Wenn man XML in das vertraute Modell „Bean → Id → Class → Abhängigkeiten“ übersetzt, wird es verständlich und vorhersagbar.
Fehler Nr.5: Den Einstiegspunkt und den Startablauf vergessen.
In der endgültigen Montage ist nicht nur wichtig, „was es gibt“, sondern auch, „was beim Start passiert“. Wenn man nicht schnell erklären kann, welches Bean das Szenario initiiert und wie es aus main() aufgerufen wird, ist das Bild immer noch unscharf. Ein klarer Einstiegspunkt ist ein Anker, der die gesamte Architektur zusammenhält.
GO TO FULL VERSION