Zurück zur WebseiteInsights

Wie sich komplexe Switch-Case-Strukturen durch Interfaces und Dependency Injection vermeiden lassen

By Marco Schäck
Published in Softwareentwicklung
October 08, 2024
3 min read
Wie sich komplexe Switch-Case-Strukturen durch Interfaces und Dependency Injection vermeiden lassen

In vielen Softwaresystemen führt die Notwendigkeit, verschiedene Geschäftsregeln auf Grundlage von unterschiedlichen Bedingungen anzuwenden, oft zu komplexem und schwer wartbarem Code. Ohne ein flexibles Design können Strukturen wie ein if-else oder ein switch-case schnell unübersichtlich werden - insbesondere wenn das System wächst. Dieser Post zeigt, wie dieses Problem gelöst werden kann. Mithilfe von Spring’s Dependency Injection (DI) und Java Streams wird ein Ansatz vorgestellt, der die Auswahl von Services vereinfacht. Dadurch entsteht ein modularerer und leichter wartbarer Code, der sich problemlos erweitern lässt - selbst wenn das System wächst.

Nehmen wir an, eine E-Commerce-Plattform muss je nach Land des Käufers unterschiedliche Steuer- oder Versandvorschriften anwenden. Um den Code modular und testbar zu halten, wird für jedes Land ein eigener Service erstellt. Trotzdem erfolgt die Auswahl des jeweiligen Services weiterhin über eine einfache if-else- oder switch-case-Strukturen. Auf den ersten Blick scheint dies praktikabel – jeder Service wird basierend auf dem Standort des Käufers ausgewählt.

Doch sobald die Plattform wächst und weitere Länder hinzukommen, wird diese Lösung zunehmend problematisch. Je mehr Länder verwaltet werden müssen, desto komplexer wird der Codeblock. Das Ergebnis: ohne ein skalierbares Design entsteht ein stark gekoppelter, monolithischer Code, der anfällig für Fehler, schwer zu testen, sowie schwer zu erweitern ist.

Doch keine Sorge – dies ist ein häufiges Problem in Softwaresystemen. Und die gute Nachricht ist: Es gibt skalierbare Lösungen, um dem entgegenzuwirken. Dieser Post zeigt eine davon: den Einsatz von Schnittstellen und der Dependency Injection von Spring, um ein sauberes, modulares System zu entwerfen. Anhand des oben genannten Beispiels wird veranschaulicht, wie diese Prinzipien den Code vereinfachen und gleichzeitig dafür sorgen, dass er flexibel und wartbar bleibt – auch wenn das System wächst.

Das Problem:

Nehmen wir an, ein Service zur Abwicklung von Regulierungsprüfungen verwendet einen „Switch-Case“-Block, um die Logik an länderspezifische Services zu delegieren. Je nach Standort des Käufers wird der passende Service ausgewählt.

Dieser Ansatz funktioniert gut, solange nur wenige Länder verwaltet werden. Doch sobald die Anzahl der Länder wächst, steigt auch die Komplexität des „Switch-Case“-Blocks, was zu den bereits genannten Problemen führt: Der Code wird monolithisch, schwer wartbar, anfällig für Fehler und schwer zu erweitern.

@Component
@RequiredArgsConstuctor
public class CheckRegulationsService {

    private final USARegulationService usaRegulationService;
    private final UKRegulationService ukRegulationService;
    private final GermanyRegulationService germanyRegulationService;
    private final FranceRegulationService franceRegulationService;

    public String checkRegulations(String country) {
        switch (country.toUpperCase()) {
            case "USA":
                return usaRegulationService.check();
            case "UK":
                return ukRegulationService.check();
            case "DE":
                return germanyRegulationService.check();
            case "FR":
                return franceRegulationService.check();
            default:
                throw new IllegalArgumentException("Unsupported country: " + country);
        }
    }
}

Die Lösung: Dependency Injection, List Injection und Streams

Um dieses Problem zu lösen und den Code skalierbarer zu gestalten, bietet sich die Dependency Injection von Spring an. Eine Liste von Services kann injiziert werden, und der passende Service wird dynamisch mithilfe von Streams ausgewählt. Dieser Ansatz ist flexibel und lässt sich einfach erweitern, ohne den bestehenden Code anzupassen. Die Lösung lässt sich folgendermaßen umsetzen:

Schritt 1: Definition eines gemeinsamen Interfaces

Zuerst wird ein Interface RegulationService definiert, die von allen länderspezifischen Services implementiert wird:

public interface RegulationService {
    String getCountryCode();
    String check();
}

Dieses Interface sorgt für eine einheitliche Struktur aller Services, was die Wartung und das Verständnis des Codes erleichtert. Standardisierte Methodensignaturen machen den Zweck eines jedes Services klarer, was die Lesbarkeit verbessert. Zudem bildet das Interface die Basis dafür, dass Spring alle länderspezifischen Services erkennt und als Liste injizieren kann.

Schritt 2: Implementierung länderspezifischer Services

Nachdem das Interface definiert wurde, wird es für jeden länderspezifischen Service implementiert. Die dabei resultierenden Services könnten wie folgt aussehen:

@Service
public class USARegulationService implements RegulationService {
    @Override
    public String getCountryCode() {
        return "USA";
    }

    @Override
    public String check() {
        return "USA regulations applied";
    }
}

@Service
public class UKRegulationService implements RegulationService {
    @Override
    public String getCountryCode() {
        return "UK";
    }

    @Override
    public String check() {
        return "UK regulations applied";
    }
}

Jeder Service ist für die spezifische Regulierungslogik seines Landes zuständig, folgt aber einem einheitlichen Vertrag. Will man nun ein neues Land hinzufügen, muss man nur einen neuen Service erstellen, der sich vom Interface ableitet – ohne den bestehenden Code anzupassen.

Schritt 3: Liste von Services injizieren und Auswahl per Streams

Sobald alle Implementierungen des Interfaces vorhanden sind, kann eine Liste der Services injiziert werden. Mithilfe von Streams kann diese Liste gefiltert und der passende Service ausgewählt werden. Um den Hauptservice dabei übersichtlich zu halten, wird diese Auswahllogik in eine separate Klasse ausgelagert. Diese separate Klasse nennen wir RegulationServiceSelector.

@Component
@RequiredArgsConstructor
public class RegulationServiceSelector {

    private final List<RegulationService> regulationServices;

    public RegulationService getRegulationService(String country) {
        return regulationServices.stream()
                .filter(service -> service.getCountryCode().equalsIgnoreCase(country))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("No regulation service found for country: " + country));
    }
}

Der RegulationServiceSelector filtert die Services anhand des Ländercodes und gibt den passenden Service zurück. Wird kein passender Service gefunden, so wirft er eine Exception.

Schritt 4: Integration des Selektors in den Hauptservice

Nun wird der CheckRegulationsService so angepasst, dass der RegulationServiceSelector für die dynamische Auswahl des passenden Services genutzt wird. Dadurch wird der bisher benötigte „Switch-Case“-Block überflüssig.

@Service
@RequiredArgsConstructor
public class CheckRegulationService {

    private final RegulationServiceSelector regulationServiceSelector;

    public String checkRegulations(String country) {
        final RegulationService regulationService = regulationServiceSelector.getRegulationService(country);
        return regulationService.check();
    }
}

Vorteile dieses Ansatzes:

  • Sauberer Code: Die Streams vereinfachen die Logik und reduzieren die Anzahl der Abfragen. Zudem schafft das Interface einen einheitlichen Vertrag für alle Services, was Klarheit und Konsistenz fördert.
  • Erweiterbarkeit: Neue länderspezifische Services können leicht hinzugefügt werden, ohne den Hauptservice oder die Auswahllogik anpassen zu müssen.
  • Klare Verantwortlichkeit: Durch die Trennung der Verantwortlichkeiten wird das Design verbessert und die Wartbarkeit erleichtert. Der RegulationServiceSelector übernimmt die Auswahllogik, der Hauptservice orchestriert die Aufrufe, und die länderspezifischen Services verwalten ihre Regeln.
  • Bessere Testbarkeit: Das modulare Design ermöglicht das isolierte Testen jeder Komponente. Dies verringert den Aufwand für Mocking und erleichtert das Testen verschiedener Szenarien. All dies resultiert in einer höheren Zuverlässigkeit des Systems.

Schlussfolgerung:

Durch die Verwendung von Spring’s Dependency Injection und Java Streams lässt sich ein flexibler und simpel wartbarer Mechanismus zur Auswahl von Regulierungsservices entwickeln. Dieses Muster ist besonders hilfreich, wenn unterschiedliche Geschäftsregeln basierend auf Bedingungen wie Ländern, Kundentypen oder Produktkategorien angewendet werden müssen. Es sorgt für eine vereinfachte Codebasis und erleichtert die Skalierung sowie Erweiterung, wenn neue Anforderungen entstehen.

This post was originally published in English on Medium.com.
Co-Author: Andreas Riepl


Tags

#spring-boot#softwareentwicklung#clean-code
Previous Article
Prozessautomatisierung in der Intralogistik bei DM
Marco Schäck

Marco Schäck

Fullstack Entwickler

Ähnliche Beiträge

Component-Tests mit JUnit
July 01, 2022
3 min
© 2024, All Rights Reserved.

Links

StartseiteLösungenBPM TrainingKarriereInsights

Social Media