CodeGym /Kurse /Spring Core /Lesbares Wiring in ContextFlow

Lesbares Wiring in ContextFlow

Spring Core
Level 8, Lektion 4
Verfügbar

1. Die Idee des lesbaren Wirit ng

Wenn man mit Spring anfängt, tappt man leicht in die Falle: man denkt, der Container sei eine intelligente Box, die jede Mehrdeutigkeit selbst auflöst, also kann man sich um die Lesbarkeit des Wiring nicht kümmern. Aber nach ein paar Wochen (oder nach ein paar Kommilitonen, die versuchen, Ihren Code zu verstehen) stellt sich heraus, dass die „intelligente Box“ architektonische Vereinbarungen nicht ersetzt: wo ist unser Default? wo ist die bewusste Wahl? wo wird wirklich der gesamte Satz von Implementierungen benötigt?

In ContextFlow ist dies besonders gut am Beispiel von NotificationSender und DiscountPolicy zu sehen. Solange wir nur eine Implementierung des Interfaces hatten, sah der Konstruktor des Services unschuldig aus: „Ich brauche einen NotificationSender“. Aber sobald EmailNotificationSender, SmsNotificationSender und ConsoleNotificationSender auftauchen, hört der Container auf zu raten – und das zu Recht. Und hier beginnt der erwachsene Teil: nicht einfach „den Fehler NoUniqueBeanDefinitionException beheben“, sondern dafür sorgen, dass das Lesen des Konstruktors die Wahrheit über die Absichten der Klasse sagt.

Ein nützlicher Gedanke für heute lautet: Wiring ist auch ein Teil des Designs. Es sollte genauso leicht lesbar sein wie eine gute Methode calculateTotal(). Wenn Wiring zu einem „magischen Satz von Annotationen wird, der funktioniert, aber unklar ist, warum“ – dann sind Sie wieder auf dem Weg zum manuellen new, nur diesmal mit Zaubersprüchen.

2. Abhängigkeitsmodi: Default, explizit, Register

In Spring gibt es, wenn Sie mehrere Implementierungen eines Interfaces haben, eigentlich nur drei vernünftige „Anforderungsformen“ an den Container. Und diese Formen kann man buchstäblich an der Signatur des Konstruktors erkennen. Das ist sehr praktisch: Sie öffnen eine Klasse – und verstehen bereits am Konstruktor, welchen Abhängigkeitsvertrag diese Klasse hat, ohne Methodenrümpfe lesen oder raten zu müssen, „was der Container wohl ausgewählt hat“.

Halten wir diese drei Modelle in einer Tabelle fest, denn für das Gehirn eines Anfängers ist das einfacher (und für das Gehirn des Dozenten – beruhigender):

Was die Klasse möchte Wie die Abhängigkeit aussieht Wie lösen wir das in Spring Was bedeutet das inhaltlich
„Ich brauche eine normale Variante“ NotificationSender sender @Primary / @Fallback (oder den Kandidaten eindeutig machen) Die Klasse vertraut dem Container, den „Standard“ zu wählen
„Ich brauche genau diese Variante“ @Qualifier("smsNotificationSender") NotificationSender sender @Qualifier am Injektionspunkt Die Wahl ist wichtig und sollte im Code sichtbar sein
„Ich brauche den gesamten Satz, ich wähle selbst“ Map<String, NotificationSender> senders Collection Injection (List/Set/Map) Die Klasse ist ein Router/Katalog/Aggregator von Strategien

Und jetzt das Wichtigste: Diese Modelle können in einer Anwendung gemischt werden (und sollten es auch!). Sie können durchaus einen Service haben, der den „Default“ DiscountPolicy verwendet, einen anderen Service, der einen bestimmten NoDiscountPolicy für die Berechnung des „Nettopreises“ benötigt, und einen dritten, der eine List<DiscountPolicy> als Katalog verfügbarer Strategien speichert.

Damit das nicht im Chaos endet, ist es hilfreich, eine einfache Regel im Kopf zu behalten: Wenn eine Auswahl an zehn Stellen wiederholt wird, ist es keine Auswahl mehr – es ist ein Default. Wenn eine Auswahl nur an einer Stelle wichtig ist, machen Sie sie explizit über @Qualifier. Wenn ein Service tatsächlich mit mehreren Implementierungen arbeitet, tun Sie nicht so, als bräuchten Sie „einen einzigen Bean“: Fordern Sie eine Sammlung an und nennen Sie die Dinge beim Namen.

Im Folgenden werden wir diese Idee anhand von ContextFlow-Beispielen in einem „lesbaren Schema“ zusammenführen.

3. Default-Choice: @Primary und @Fallback

Wenn eine Klasse ehrlich eine normale Variante benötigt, sind ständige Qualifier nur Rauschen. In ContextFlow deckt das Default-Choice zwei verschiedene Situationen ab: Bei NotificationSender gibt es einen gewohnten Standardkanal, bei DiscountPolicy gibt es einen Reserve-NoDiscountPolicy, der eine sinnvollere Richtlinie nicht stören soll.

import org.springframework.context.annotation.Fallback;
import org.springframework.stereotype.Component;

@Component("noDiscountPolicy")
@Fallback // Reserve-Variante: wird nur verwendet, wenn es keine anderen DiscountPolicy gibt
public class NoDiscountPolicy implements DiscountPolicy {

    // Basisstrategie: kein Rabatt, der Betrag ändert sich nicht
    public int apply(int total) { return total; }
}

Auf der Seite von NotificationSender sieht die gleiche Idee wie ein einzelner @Primary-Standardkanal aus: Dienste müssen nicht immer wieder dieselbe Wahl wiederholen.

Der Konstruktor des Dienstes bleibt dabei ehrlich einfach:

import org.springframework.stereotype.Service;

@Service
public class OrderPricingService {
    private final DiscountPolicy discountPolicy;

    public OrderPricingService(DiscountPolicy discountPolicy) {
        // Die Klasse fordert einen DiscountPolicy an und vertraut dem Container die normale Auswahl an
        this.discountPolicy = discountPolicy;
    }
}

Ein solcher Konstruktor liest sich nur dann normal, wenn es für den Typ eine klare Vereinbarung gibt: ein @Primary, ein normaler Kandidat über der Fallback-Reserve oder überhaupt eine einzige Implementierung. Hier wird das Default-Choice nicht um der „Magie“ willen benötigt, sondern um sich wiederholende Auswahlen in jedem Service zu vermeiden.

4. Explizite Wahl: @Qualifier

Sobald der Default nicht mehr die Bedeutung der Klasse widerspiegelt, muss die Wahl explizit werden. Hier geht es bei @Qualifier nicht mehr darum, „den Container zu reparieren“, sondern darum, dass der Konstruktor selbst die Wahrheit sagt.

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

@Service
public class EmergencyNotificationService {
    private final NotificationSender sender;

    public EmergencyNotificationService(
            @Qualifier("smsNotificationSender") NotificationSender sender) {
        // Dieser Dienst arbeitet sinngemäß mit dem SMS-Kanal
        this.sender = sender;
    }
}

Solcher Code liest sich ohne Rateaufwand: Der Dienst „versendet nicht einfach etwas“, sondern arbeitet mit einem bestimmten Kanal. Wenn sich die Wahl nicht auf die Bedeutung des Dienstes bezieht, sondern auf eine Wiring-Entscheidung, kann man denselben @Qualifier auf den Parameter der @Bean-Methode setzen und die Klasse selbst neutral lassen. Der Hauptunterschied ist einfach: Default verbirgt Routine, Qualifier zeigt eine bewusste Wahl.

5. Strategieregister: Map<String, T>

Bisher war NotificationDispatchService als lehrreiches One-Sender-Beispiel praktisch: daran konnte man leicht sehen, wo der Container über mehrere Kandidaten stolpert. Sobald die Kanalauswahl zu einer echten Aufgabe der Anwendung wird, liest man einen solchen Dienst besser als kleinen Router.

Map<String, NotificationSender> ist hier gerade als Strategieregister nützlich. In einer frühen mechanischen Demonstration konnte man den Bean-Namen nach Konvention berechnen. Für lesbares Wiring ist es besser, die Kanalauswahl an einer Stelle explizit zu halten, anstatt darauf zu hoffen, dass sich die String-Formel von selbst errät.

import java.util.Map;
import org.springframework.stereotype.Service;

@Service
public class NotificationDispatchService {
    private final Map<String, NotificationSender> senders;

    public NotificationDispatchService(Map<String, NotificationSender> senders) {
        // Spring sammelt selbst die Map: Schlüssel = Bean-Name, Wert = Interface-Implementierung
        this.senders = senders;
    }

    public void send(NotificationChannel channel, String message) {
        // Die Kanalauswahl ist explizit und lebt an einer Stelle
        String beanName = switch (channel) {
            case EMAIL -> "emailNotificationSender";
            case SMS -> "smsNotificationSender";
            case CONSOLE -> "consoleNotificationSender";
        };

        NotificationSender sender = senders.get(beanName);
        if (sender == null) {
            throw new IllegalArgumentException("Unknown channel: " + channel);
        }

        sender.send(message);
    }
}

In dieser Form wird NotificationDispatchService zu einer normalen Arbeitsvariante für ContextFlow: Die anderen Dienste kennen nur den Kanal und die Nachricht, und die Details der Sender-Auswahl leben an einer Stelle.

6. Schema von ContextFlow: Default + explizit + Map

Jetzt zerfällt das Bild nicht: Ein und dasselbe Projekt verwendet alle drei Wiring-Modi, aber jeder an seinem Platz.

  • OrderPricingService fordert einen DiscountPolicy an und erhält die „Haupt“-Variante, während NoDiscountPolicy als Fallback bleibt.
  • EmergencyNotificationService fordert einen bestimmten NotificationSender über @Qualifier an, weil der Sinn des Dienstes SMS ist.
  • NotificationDispatchService fordert alle NotificationSender an, weil er ein Router ist.

Wenn man das als Graph darstellt, ergibt sich ein sehr verständliches Bild:

flowchart TD
    OPS[OrderPricingService] --> DP[DiscountPolicy]
    DP --> LDP[LoyalCustomerDiscountPolicy]
    DP --> NDP["NoDiscountPolicy
@Fallback"] ENS[EmergencyNotificationService] --> SMS["SmsNotificationSender
@Qualifier"] NDS[NotificationDispatchService] --> MAP["Map<String, NotificationSender>"] MAP --> CNS["ConsoleNotificationSender
@Primary"] MAP --> EMS[EmailNotificationSender] MAP --> SMS

Nun zeigen wir ein kleines Stück eines Use-Case eines Dienstes, der das nutzt. Angenommen, OrderPlacementService wählt die Sender-Implementierung nicht direkt – er ruft einfach den Dispatch pro Kanal auf.

import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {
    private final NotificationDispatchService notificationDispatchService;

    public OrderPlacementService(NotificationDispatchService notificationDispatchService) {
        // Der Dienst kennt keine Implementierungen von Sendern – er delegiert die Auswahl an den Router
        this.notificationDispatchService = notificationDispatchService;
    }
}

Und irgendwo im Szenario:

notificationDispatchService.send(NotificationChannel.CONSOLE, "Order created!")
CONSOLE: Order created!

Aus Sicht des Code-Lesers wirkt das sehr ehrlich. OrderPlacementService kennt @Primary nicht, kennt keine Bean-Namen, enthält kein new SmsNotificationSender(). Er verwendet den Dienst einfach entsprechend seinem Zweck. Und die ganze „Tücke“ der Auswahl lebt ordentlich dort, wo sie hingehört: im Wiring/Routing.

Wenn Sie sich dabei ertappen, dass Sie Folgendes tun möchten:

// Anti-Pattern: Manuelle Auswahl der Implementierung im Geschäftscode, anstatt dies dem Container/Router zu überlassen
if (channel == NotificationChannel.SMS) {
    new SmsNotificationSender().send(message);
} else {
    new EmailNotificationSender().send(message);
}

dann können Sie ruhig durchatmen und sagen: „Aha, wir versuchen wieder, in die Tage 1–2 zurückzufallen.“ Und zum Container zurückkehren.

7. Typische Wiring-Fehler

Fehler Nr. 1: „Habe die Mehrdeutigkeit mit new im Service behoben – und gut.“
Das sieht nach einer schnellen Reparatur aus, aber in Wirklichkeit holen Sie die Entscheidung aus dem Container zurück in den Geschäftscode und erzeugen versteckte Abhängigkeiten. In einer Woche möchten Sie den SMS-Absender durch einen anderen ersetzen, und Sie müssen das gesamte Projekt nach new SmsNotificationSender() durchsuchen. Das ist dasselbe manuelle Wiring, nur im Profil.

Fehler Nr. 2: „Lassen Sie uns List<T> injizieren und den Default als list.get(0) nehmen.“
Solcher Code „funktioniert manchmal zufällig“, solange sich die Reihenfolge der Kandidaten nicht ändert, solange keine neue Implementierung hinzugefügt wird, solange die Abhängigkeiten nicht aktualisiert werden. Aber das ist ein schlechter Vertrag: Sie tun so, als wäre List ein „einzelner Bean“ und keine Sammlung. Wenn ein Default benötigt wird, sollte er mit @Primary deklariert werden, nicht mit „wer zuerst kommt, mahlt zuerst“.

Fehler Nr. 3: „Ich habe alles unter Kontrolle, also setze ich @Qualifier überall hin.“
Normalerweise beginnt so der „Qualifier-Spam“: Die Hälfte der Konstruktoren besteht aus sich wiederholenden Strings "consoleNotificationSender", "consoleNotificationSender", "consoleNotificationSender". Das ist keine Kontrolle, sondern Rauschen. Wenn sich dieselbe Auswahl massiv wiederholt, ist das ein Signal, einen Default (@Primary) zu setzen oder die Auswahl in einen spezialisierten Dienst (z. B. Router) auszulagern.

Fehler Nr. 4: „Bean-Name ist Zufall – nenne ich irgendwann später.“
Sobald Sie anfangen, Map<String, T> zu verwenden, hört der Bean-Name auf, ein dekoratives Detail zu sein. Er wird zum Schlüssel in Ihrer „Strategietabelle“. Wenn Sie Beans beliebig benennen (oder nach Refactoring unverständliche Namen hinterlassen), verwandeln Sie das Routing selbst in ein Rätsel. Namen wie smsNotificationSender und loyalCustomerDiscountPolicy sind nicht „viele Buchstaben“, sondern Dokumentation.

Fehler Nr. 5: „Router überall“: Injizieren von Map<String, NotificationSender> in jeden Service.
Manchmal möchte man nach dem Kennenlernen von Map, dass jeder Service selbst die Implementierung per Schlüssel auswählt. Das verteilt die Auswahl wieder über die gesamte Anwendung. Viel sauberer ist es, wenn die Map an einer Stelle lebt – in NotificationDispatchService – und die anderen Dienste sie einfach aufrufen, ohne die Details der Container-Küche zu kennen.

1
Aufgabe
Spring Core, Level 8, Lektion 4
Gesperrt
Default und explizite Wahl in einer Anwendung
Default und explizite Wahl in einer Anwendung
1
Aufgabe
Spring Core, Level 8, Lektion 4
Gesperrt
Standardrichtlinie und Richtlinienregister
Standardrichtlinie und Richtlinienregister
1
Umfrage/Quiz
Abhängigkeitsinjektion, Level 8, Lektion 4
Nicht verfügbar
Abhängigkeitsinjektion
Beans und Qualifikatoren
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION