1. Service Layer: Den DI-Stil vereinheitlichen
Wenn das Projekt eine Stadt ist, dann ist die Service-Ebene darin wie das Verkehrssystem: Es ist zwar nicht das schönste Viertel, aber davon hängt ab, ob der „Auftrag erstellt“ zum „Audit geschrieben“ und „Benachrichtigung gesendet“ gelangt. Gerade die Services werden am häufigsten zum Zentrum des Objektgraphen, daher potenzieren sich dort alle Merkwürdigkeiten mit DI auf die gesamte Anwendung.
In ContextFlow sind Services die Klassen, die Szenarien orchestrieren: Auftragserstellung, Auftragsstornierung, Berichterstellung. Hier fließen Abhängigkeiten zum Speicher, zur Preislogik, zum Audit und zu Benachrichtigungen zusammen. Und wenn Services auf unterschiedliche Weise injiziert werden – teils über Felder, teils über Konstruktoren, teils „ich mache hier leise ein new innerhalb der Methode“ – bekommt das Projekt einen Charakter. Meistens einen unangenehmen. Daher bringen wir am Ende des Tages die gesamte Service-Ebene auf einen einheitlichen Stil, damit das Projekt an jeder Stelle gleich lesbar ist.
Karte der Services in ContextFlow
Vor dem Refactoring ist es nützlich, eine einfache Sache zu tun: Nicht den Code „aus Inspiration“ umschreiben, sondern eine Karte erstellen – welche Services haben wir und welche Abhängigkeiten sind für sie wirklich obligatorisch. Das klingt langweilig, spart aber in der Praxis Stunden, weil Sie aufhören zu raten „warum dieser Service überhaupt existiert“ und beginnen, das System als eine Menge von Rollen zu sehen.
In ContextFlow sind die Rollen bewusst „dünn“ gehalten, damit wir Spring lernen und nicht ein neues Amazon bauen. Dennoch hat jede Rolle verständliche obligatorische „Kollaborateure“. Unten ist ein aktueller Ausschnitt der Service-Ebene.
| Klasse (Service) | Was sie sinngemäß tut | Obligatorische Abhängigkeiten (über Konstruktor) |
|---|---|---|
| OrderPlacementService | erstellt einen Auftrag | OrderIdGenerator, , , , |
| OrderCancellationService | storniert einen Auftrag | OrderStore, , |
| OrderPricingService | berechnet die Summe | DiscountPolicy |
| AuditService | schreibt Business-Audit | AuditWriter |
| NotificationDispatchService | sendet Benachrichtigungen | NotificationSender |
| ReportingService | erstellt einen Bericht | OrderStore, |
| ScenarioRunner | führt Szenarien aus | OrderPlacementService, , |
Diese Karte behalten wir als Arbeitsstand des Tages: Sie listet nicht jede Helper-Klasse des Projekts auf, sondern erfasst die obligatorischen Abhängigkeiten der Services. Anhand dieser Karte lässt sich später bequem prüfen, ob die Rolle eines Services ausufert und ob irgendwo ein verstecktes new aufgetaucht ist.
2. Aufwärmübung: AuditService und NotificationDispatchService
Es ist am besten, die Umstellung auf Constructor Injection mit kleinen und offensichtlichen Services zu beginnen, damit sich die Hand „daran erinnert“, wie es geht, und Sie einen schnellen Gewinn an Lesbarkeit sehen. AuditService und NotificationDispatchService sind ideal: Jeder hat eine obligatorische Abhängigkeit, die Rolle ist klar, und das Refactoring dauert Minuten, bringt aber viel Nutzen.
Zuerst halten wir die Ports fest – die Interfaces, von denen die Services abhängen. Sie sind bereits von den vergangenen Tagen vorhanden, aber ein kleiner Ausschnitt hilft, das Bild im Kopf zu behalten:
public interface AuditWriter {
// Port für die Audit-Infrastruktur: Die Service-Ebene soll nicht wissen,
// WOHIN und WIE genau das Audit geschrieben wird (Konsole, Datei, DB, Log usw.).
void write(String message);
}
Jetzt AuditService im Constructor-Driven-Stil. Beachten Sie: Kein einziges @Autowired ist nötig, weil es nur einen Konstruktor gibt und Spring hervorragend selbst herausfinden kann, dass genau dieser verwendet werden soll.
import com.example.contextflow.domain.ports.AuditWriter;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// Die Abhängigkeit ist obligatorisch: Ohne sie hat der Service keinen Sinn.
private final AuditWriter auditWriter;
public AuditService(AuditWriter auditWriter) {
// Der Konstruktor fixiert die Abhängigkeit einmal bei der Erstellung des Beans.
this.auditWriter = auditWriter;
}
public void audit(String message) {
auditWriter.write(message);
}
}
Und genauso NotificationDispatchService. Die ganze „Magie“ liegt nicht in der Annotation, sondern darin, dass die Abhängigkeit nach außen verlagert wurde und Teil des Klassenvertrags geworden ist.
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
// Der Benachrichtigungssender ist eine Infrastrukturabhängigkeit.
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
// Wichtig: Erzeuge keinen sender per new innerhalb des Services.
this.sender = sender;
}
public void dispatch(String message) {
sender.send(message);
}
}
Diese Klassen werden dann als Services auf Szenarienebene verwendet: audit und dispatch, während die Details des Schreibens und Sendens bei AuditWriter und NotificationSender bleiben.
Wenn Sie jetzt denken: „Das sind doch nur zwei Felder und ein Konstruktor, warum so feierlich?“ – das ist eine normale Reaktion. Constructor Injection sieht oft „zu einfach aus, um wichtig zu sein“. Aber der Trick liegt im Maßstab: Wenn es 30 solcher Klassen gibt, verwandelt der einheitliche Stil das Lesen des Projekts von „Archäologie“ in „einfaches Lesen“.
3. OrderPlacementService und Constructor Injection
OrderPlacementService ist normalerweise der am stärksten mit Abhängigkeiten belastete Service: Er orchestriert den Haupt-Use-Case, daher braucht er wirklich mehr Kollaborateure. Gerade bei ihm spürt man den Unterschied zwischen „Abhängigkeiten sind versteckt“ und „Abhängigkeiten sind sichtbar“ am deutlichsten. Und er verwandelt sich am häufigsten in einen „Gott-Service“, wenn man die Grenzen der Verantwortung nicht beachtet.
Beginnen wir mit dem Konstruktor und den final-Feldern. Ja, der Konstruktor ist nicht auf zwei Zeilen – und das ist normal: Der Service hängt tatsächlich von mehreren Dingen ab. Unnormal wird es später, wenn er anfängt, „von allem“ abzuhängen. Wenn Preisbildung, Audit und Benachrichtigung zum Szenario der Auftragserstellung gehören, dann haben sie im Konstruktor neben der ID-Generierung und dem Speichern ihren Platz.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// Generator für Auftrags-IDs – obligatorischer Kollaborateur.
private final OrderIdGenerator orderIdGenerator;
// Speicher für Aufträge – obligatorischer Kollaborateur zum Speichern des Szenarioergebnisses.
private final OrderStore orderStore;
// Preisberechnungsservice – Teil der Geschäftsregeln, ebenfalls obligatorisch.
private final OrderPricingService pricingService;
// Audit und Benachrichtigungen – ebenso sichtbare Teile des Szenarios, keine versteckten Seiteneffekte.
private final AuditService auditService;
private final NotificationDispatchService notificationDispatchService;
public OrderPlacementService(OrderIdGenerator orderIdGenerator,
OrderStore orderStore,
OrderPricingService pricingService,
AuditService auditService,
NotificationDispatchService notificationDispatchService) {
// Der Konstruktor ist der einzige Ort, an dem das Objekt „zusammengebaut“ wird.
this.orderIdGenerator = orderIdGenerator;
this.orderStore = orderStore;
this.pricingService = pricingService;
this.auditService = auditService;
this.notificationDispatchService = notificationDispatchService;
}
}
So sieht OrderPlacementService im aktuellen Projektausschnitt aus: Wenn das Szenario tatsächlich Preisberechnung, Speicherung, Audit und Benachrichtigung umfasst, dann sollten all diese Rollen oben in der Klasse stehen, nicht in Methoden versteckt sein.
Um den praktischen Sinn zu spüren, schauen wir uns ein kleines Stück des Szenarios an. Die Methode muss nicht „vollständig geschäftsrealistisch“ sein; für uns ist wichtig, dass sie Abhängigkeiten aufruft und die Abhängigkeiten nicht innerhalb erzeugt werden.
public String placeOrder(CreateOrderCommand cmd) {
// 1) Auftrags-ID generieren.
String id = orderIdGenerator.nextId();
// 2) Endsumme über den Pricing-Service berechnen.
var total = pricingService.calculateTotal(cmd.items());
// 3) Auftrag im Speicher ablegen.
orderStore.save(id, cmd.customerId(), cmd.items(), total);
// 4) Seiteneffekte durch bereits injizierte Services erfassen.
auditService.audit("ORDER_PLACED: " + id);
notificationDispatchService.dispatch("Order created: " + id);
// 5) ID als Ergebnis des Szenarios zurückgeben.
return id;
}
Selbst ein so kurzes Methodenstück zeigt die Rolle des Services: Er orchestriert Schritte, anstatt selbst Rabatte zu berechnen, in die Konsole zu schreiben oder den Benachrichtigungskanal per if-else auszuwählen.
4. OrderCancellationService ohne versteckte Abhängigkeiten
Die Stornierung eines Auftrags ist ein gutes Beispiel dafür, warum es nützlich ist, einen eigenen Service für jedes Szenario zu haben. Anfänger versuchen oft, alles in eine Klasse „OrderService“ zu packen, weil „das ist ja alles über Aufträge“. Und dann hat diese Klasse plötzlich zwanzig Methoden und zehn Abhängigkeiten und beginnt, an ein Schweizer Taschenmesser zu erinnern, das auch noch ein Löffel sein will.
OrderCancellationService hat normalerweise weniger Abhängigkeiten als der Placement-Service und liest sich noch angenehmer. Wichtig ist, dass die Orchestrierungsabhängigkeiten sofort sichtbar sind: Wir brauchen Speicher, Audit und Benachrichtigungen.
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
// Zugriff auf den Auftragszustand erforderlich, um einen bestimmten Auftrag zu stornieren.
private final OrderStore orderStore;
// Audit und Benachrichtigungen – obligatorische Teile des Stornierungsszenarios.
private final AuditService auditService;
private final NotificationDispatchService notificationDispatchService;
public OrderCancellationService(OrderStore orderStore,
AuditService auditService,
NotificationDispatchService notificationDispatchService) {
// Constructor Injection: Alles Obligatorische liegt „im Schaufenster“.
this.orderStore = orderStore;
this.auditService = auditService;
this.notificationDispatchService = notificationDispatchService;
}
}
Und ein kleiner Szenario-Ausschnitt, um zu sehen, was der Service tatsächlich tut. Wieder: Kein new, keine Suche nach Beans, nur Arbeit über die bereits injizierten Abhängigkeiten.
public void cancel(String orderId) {
// 1) Zustand des Auftrags im Speicher ändern.
orderStore.cancel(orderId);
// 2) Business-Audit schreiben und Benachrichtigung senden.
auditService.audit("ORDER_CANCELLED: " + orderId);
notificationDispatchService.dispatch("Order cancelled: " + orderId);
}
Hier ist gut zu sehen, dass die Stornierung eines Auftrags ein separates Szenario mit einem eigenen Satz obligatorischer Abhängigkeiten ist, nicht eine zufällige Methode in einem riesigen OrderService.
5. ReportingService und ScenarioRunner
ReportingService und Berichte
Bei Berichten gibt es eine typische Falle: Es scheint, dass man, da man „eine Datei erstellt“, direkt im Service einen FileWriter öffnen, einen String zusammensetzen, schreiben und schließen kann. In einem Übungsprojekt funktioniert das sogar. Aber sobald Sie das Berichtsformat oder den Ausgabeort ändern möchten, stellt sich plötzlich heraus, dass der Service mit Infrastrukturdetails aufgebläht ist. Deshalb machen wir ReportFormatter zu einer separaten Abhängigkeit.
Wir sind bereits daran gewöhnt, dass ein Teil der Infrastruktur bequem über @Bean registriert wird. Aber für den Service ist es nicht wichtig, wie genau die Abhängigkeit registriert ist. Wichtig ist, dass die Abhängigkeit über den Konstruktor hereinkommt und stabil bleibt.
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// Datenquelle für den Bericht: Der Service weiß, WAS zu holen ist, aber nicht WIE es gespeichert wird.
private final OrderStore orderStore;
// Formatierer – separate Rolle: Wandelt Daten in einen String / ein Berichtsformat um.
private final ReportFormatter reportFormatter;
public ReportingService(OrderStore orderStore, ReportFormatter reportFormatter) {
// Alle obligatorischen Abhängigkeiten werden explizit übergeben.
this.orderStore = orderStore;
this.reportFormatter = reportFormatter;
}
}
Und hier ein Minibeispiel, das die Interaktion zeigt: Der Service holt Daten aus OrderStore, übergibt sie dem Formatierer, erhält einen String (oder ein anderes Ergebnis) und entscheidet dann, was damit zu tun ist.
public String buildDailyReport() {
// 1) Daten holen (in der Realität könnte hier eine Filterung nach Datum usw. sein).
var orders = orderStore.findAll();
// 2) Formatierung delegieren, um Business-Orchestrierung und Präsentation nicht zu vermischen.
return reportFormatter.format(orders);
}
Beachten Sie, wie ReportingService ein „Service“ geblieben ist und kein „Dateimanager“ wurde. Selbst wenn später eine komplexere Ausgabeinfrastruktur hinzukommt, bleibt das Prinzip erhalten: Die Business-Schicht fordert „erstelle einen Bericht“, und die Infrastruktur entscheidet „wie genau“.
ScenarioRunner und Wiring ohne new
ScenarioRunner ist unser „Dirigent“, der Demonstrationsszenarien der Anwendung ausführt. Es ist kein reiner Business-Service, aber methodisch sehr nützlich: Es zeigt, dass man eine Startkomponente haben kann, die Services erhält und ihre Methoden aufruft. Und gerade am ScenarioRunner kann man leicht sehen: Wiring sollte entweder im Container oder in der Konfiguration leben, nicht mitten in Business-Methoden.
Zuerst der ScenarioRunner selbst als normale Klasse mit Konstruktor. Sie kann über @Component registriert werden, aber in unserem Kurs ist es praktisch, die Variante über @Bean zu zeigen, damit Sie sehen: Der Injection-Stil hängt nicht von der Registrierungsart ab.
public class ScenarioRunner {
// Szenarien werden über Services gestartet, nicht per manuellem Zusammenbau von Abhängigkeiten.
private final OrderPlacementService placementService;
private final OrderCancellationService cancellationService;
private final ReportingService reportingService;
public ScenarioRunner(OrderPlacementService placementService,
OrderCancellationService cancellationService,
ReportingService reportingService) {
// Obligatorische Services über Konstruktor injizieren.
this.placementService = placementService;
this.cancellationService = cancellationService;
this.reportingService = reportingService;
}
public void run() {
// Hier werden normalerweise placementService, cancellationService und reportingService aufgerufen.
System.out.println("ContextFlow gestartet");
}
}
Jetzt die Konfiguration, die den ScenarioRunner erstellt. Hier ist ein wichtiger Punkt: @Bean-Methoden können Parameter akzeptieren, und Spring setzt automatisch die benötigten Beans ein. Die Konfiguration bleibt dadurch schlank: Keine manuellen ctx.getBean darin, keine „inneren Fabriken“.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ScenarioConfig {
@Bean
public ScenarioRunner scenarioRunner(OrderPlacementService placementService,
OrderCancellationService cancellationService,
ReportingService reportingService) {
// Spring übergibt die benötigten Beans automatisch als Methodenparameter.
return new ScenarioRunner(placementService, cancellationService, reportingService);
}
}
So bleibt das Wiring an der Oberfläche: Die Abhängigkeiten des ScenarioRunner sind direkt in der Signatur der @Bean-Methode sichtbar und werden nicht später aus dem Container während des Szenarios geholt.
Und schließlich der Start der Anwendung. Dies ist nicht das Hauptthema des Tages, aber es ist nützlich zu sehen, dass unser gesamter, sauberer Constructor-Driven-Service-Layer problemlos in einem gewöhnlichen AnnotationConfigApplicationContext läuft.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Kontext über try-with-resources schließen, um Ressourcen korrekt freizugeben.
try (var ctx = new AnnotationConfigApplicationContext(ContextFlowAppConfig.class)) {
// Szenario-Runner aus dem Kontext holen und Demo-Szenario starten.
ctx.getBean(ScenarioRunner.class).run();
}
}
}
Wenn Sie jetzt das Gefühl haben, dass der Code „langweiliger“ geworden ist (weniger Magie, mehr explizite Konstruktoren) – herzlichen Glückwunsch: Das ist ein normales Zeichen dafür, dass das Projekt erwachsen wird. Echte Stabilität sieht meist ein bisschen langweilig aus. Wie gute Bremsen: Niemand begeistert sich für Bremsen, bis sie gebraucht werden.
6. Lesen des Abhängigkeitsgraphen anhand von Konstruktoren
Wenn Sie einen einheitlichen Constructor Injection in der Service-Ebene haben, entsteht ein großartiger Nebeneffekt: Sie können das Projekt wie eine U-Bahn-Karte lesen. Der Konstruktor ist die „Umsteigestation“: An ihm sieht man, wohin der Service angeschlossen ist. Und selbst wenn Sie noch nicht stark in Spring sind, können Sie bereits eine erwachsene Diagnose stellen: „Dieser Service sollte nicht von dieser Komponente abhängen.“
So könnte ein vereinfachter Abhängigkeitsgraph von ContextFlow in unserem aktuellen Stadium aussehen:
flowchart TD
ScenarioRunner --> OrderPlacementService
ScenarioRunner --> OrderCancellationService
ScenarioRunner --> ReportingService
OrderPlacementService --> OrderIdGenerator
OrderPlacementService --> OrderStore
OrderPlacementService --> OrderPricingService
OrderPlacementService --> AuditService
OrderPlacementService --> NotificationDispatchService
OrderCancellationService --> OrderStore
OrderCancellationService --> AuditService
OrderCancellationService --> NotificationDispatchService
ReportingService --> OrderStore
ReportingService --> ReportFormatter
OrderPricingService --> DiscountPolicy
AuditService --> AuditWriter
NotificationDispatchService --> NotificationSender
Wichtig ist, dass dieses Schema fast automatisch aus dem Code „wiederhergestellt“ wird. Sie brauchen kein separates Dokument. Wenn jemand im Team eine Abhängigkeit hinzugefügt hat, z. B. OrderPlacementService plötzlich von ReportFormatter abhängt, sehen Sie das direkt im Konstruktor und können die Frage stellen: „Warum?“. Und das ist einer der Hauptgründe, warum wir heute so beharrlich den Stil vereinheitlicht haben: Der Konstruktor wird zur Dokumentation, die nicht veraltet, weil der Compiler nicht zulässt, dass sie veraltet.
7. Typische Fehler bei Constructor Injection
Fehler Nr. 1: Service auf Konstruktor umgestellt, aber das new einer konkreten Implementierung im Szenario belassen.
Z. B. erhält OrderPlacementService ehrlich NotificationDispatchService, macht aber irgendwo in der Methode trotzdem ein new ConsoleNotificationSender(). In diesem Moment beginnt der Service wieder, selbst die Infrastruktur auszuwählen, und der einheitliche Abhängigkeitsgraph bricht auseinander. Wenn der Service eine Abhängigkeit benötigt, muss sie von außen kommen, nicht mitten im Use Case geboren werden.
Fehler Nr. 2: Die Service-Ebene nur zur Hälfte vereinheitlicht.
Ein Service per Constructor Injection, der Nachbar per Field Injection, der dritte per Setter „weil ich zu faul zum Umschreiben bin“. Von außen scheint alles zu funktionieren, aber das Lesen und Diagnostizieren eines solchen Projekts ist schon viel schwerer: Bei einem Teil der Klassen liegt der Vertrag oben, bei einem anderen Teil ist er über die Felder verteilt. Der Sinn eines einheitlichen Stils liegt genau darin, dass der Abhängigkeitsgraph in jedem Service gleich lesbar ist.
Fehler Nr. 3: Das Signal eines großen Konstruktors ignorieren und weiterhin neue Rollen in den Service packen.
Wenn OrderPlacementService plötzlich auch noch ReportFormatter, einen Datei-Writer und etwas für den Export verlangt, liegt das Problem nicht am Konstruktor als solchem. Das Problem ist, dass der Use-Case-Service aufgebläht ist und ein Refactoring der Verantwortlichkeiten verlangt, nicht ein annotatorisches Make-up. Constructor Injection ist hier gerade nützlich, weil sie die Überlastung ehrlich und ohne Maskierung zeigt.
Fehler Nr. 4: Den ScenarioRunner oder die Konfiguration in einen Service Locator verwandeln.
Sobald in run() oder in @Bean-Methoden ein ctx.getBean auftaucht, um „einen weiteren benötigten Service“ zu holen, verschwindet das explizite Wiring. Für den Einstiegspunkt ist der Container als Ort geeignet, von dem Sie den fertigen ScenarioRunner holen, aber nicht als Krücke für das versteckte Suchen von Abhängigkeiten während des Szenarios.
Fehler Nr. 5: Arbeit im Konstruktor erledigen, anstatt einfach das Objekt zusammenzubauen.
Manchmal möchte man im Konstruktor sofort einen Auftrag stornieren, einen Bericht erstellen oder eine Benachrichtigung senden – „da die Abhängigkeiten ja schon da sind“. Damit beginnt der Service, Seiteneffekte beim Start des Kontexts zu erzeugen, und das Verhalten der Anwendung wird weniger vorhersagbar. Der Konstruktor soll das Objekt zusammenbauen, das Szenario soll in normalen Methoden leben.
GO TO FULL VERSION